questions M-R
This commit is contained in:
184
backend/data/questions/majority-element-ii.yaml
Normal file
184
backend/data/questions/majority-element-ii.yaml
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
title: Majority Element II
|
||||||
|
slug: majority-element-ii
|
||||||
|
difficulty: medium
|
||||||
|
leetcode_id: 229
|
||||||
|
leetcode_url: https://leetcode.com/problems/majority-element-ii/
|
||||||
|
categories:
|
||||||
|
- arrays
|
||||||
|
- hash-tables
|
||||||
|
patterns:
|
||||||
|
- greedy
|
||||||
|
|
||||||
|
description: |
|
||||||
|
Given an integer array of size `n`, find all elements that appear **more than** `⌊n/3⌋` times.
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: "nums = [3,2,3]"
|
||||||
|
output: "[3]"
|
||||||
|
explanation: "The element 3 appears twice out of 3 elements. Since ⌊3/3⌋ = 1, and 2 > 1, the answer is [3]."
|
||||||
|
- input: "nums = [1]"
|
||||||
|
output: "[1]"
|
||||||
|
explanation: "The element 1 appears once out of 1 element. Since ⌊1/3⌋ = 0, and 1 > 0, the answer is [1]."
|
||||||
|
- input: "nums = [1,2]"
|
||||||
|
output: "[1,2]"
|
||||||
|
explanation: "Both 1 and 2 appear once out of 2 elements. Since ⌊2/3⌋ = 0, and 1 > 0, both qualify."
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `1 <= nums.length <= 5 * 10^4`
|
||||||
|
- `-10^9 <= nums[i] <= 10^9`
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
This problem extends the classic Majority Element problem. Instead of finding elements appearing more than `n/2` times, we're looking for elements appearing more than `n/3` times.
|
||||||
|
|
||||||
|
Here's the key mathematical insight: **at most two elements** can appear more than `n/3` times. Why? If three elements each appeared more than `n/3` times, we'd need more than `n` elements total — impossible!
|
||||||
|
|
||||||
|
Think of it like a three-way election where a candidate needs more than 33% of votes to win. At most two candidates can achieve this threshold. If all three had over 33%, the percentages would exceed 100%.
|
||||||
|
|
||||||
|
This observation allows us to extend the **Boyer-Moore Voting Algorithm** to track two candidates instead of one. We run a "battle royale" where elements compete for two slots. When we encounter a third distinct element, it cancels out one vote from each candidate.
|
||||||
|
|
||||||
|
At the end, we verify which candidates (if any) actually exceed the `n/3` threshold — unlike the original problem, there's no guarantee any element qualifies.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using the **Extended Boyer-Moore Voting Algorithm**:
|
||||||
|
|
||||||
|
**Step 1: Initialise two candidate slots**
|
||||||
|
|
||||||
|
- `candidate1`, `candidate2`: Will store our two potential majority elements
|
||||||
|
- `count1`, `count2`: Set to `0`, track the "strength" of each candidate
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: First pass — find the candidates**
|
||||||
|
|
||||||
|
- For each element in the array:
|
||||||
|
- If it matches `candidate1`, increment `count1`
|
||||||
|
- Else if it matches `candidate2`, increment `count2`
|
||||||
|
- Else if `count1 == 0`, adopt this element as `candidate1` and set `count1 = 1`
|
||||||
|
- Else if `count2 == 0`, adopt this element as `candidate2` and set `count2 = 1`
|
||||||
|
- Else decrement both `count1` and `count2` (three distinct elements cancel out)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Second pass — verify the candidates**
|
||||||
|
|
||||||
|
- Count actual occurrences of `candidate1` and `candidate2`
|
||||||
|
- Only include candidates that appear more than `n/3` times in the result
|
||||||
|
- Unlike the original problem, neither candidate may qualify
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
The cancellation logic works because if an element appears more than `n/3` times, it cannot be fully cancelled by all other elements, ensuring it survives as one of the two candidates.
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Forgetting the Verification Pass
|
||||||
|
description: |
|
||||||
|
Unlike Majority Element I where the majority is guaranteed, this problem may have zero, one, or two valid answers.
|
||||||
|
|
||||||
|
For example, with `nums = [1,2,3,4,5]`, no element appears more than `⌊5/3⌋ = 1` time. The Boyer-Moore phase will still produce two candidates, but neither actually qualifies.
|
||||||
|
|
||||||
|
Always verify candidates with a second pass to count their actual occurrences.
|
||||||
|
wrong_approach: "Returning candidates without verification"
|
||||||
|
correct_approach: "Count actual occurrences and filter by threshold"
|
||||||
|
|
||||||
|
- title: Using Hash Map Without Space Constraint Awareness
|
||||||
|
description: |
|
||||||
|
A hash map solution works and runs in O(n) time, but uses **O(n) space**. The follow-up specifically asks for O(1) space, which the extended Boyer-Moore algorithm achieves.
|
||||||
|
|
||||||
|
The hash map approach is acceptable if space isn't a concern, but the optimal solution uses constant space.
|
||||||
|
wrong_approach: "Hash map counting with O(n) space"
|
||||||
|
correct_approach: "Extended Boyer-Moore with O(1) space"
|
||||||
|
|
||||||
|
- title: Incorrect Order of Candidate Checks
|
||||||
|
description: |
|
||||||
|
The order of checks matters in the first pass. You must check if the element matches existing candidates *before* checking if a slot is available.
|
||||||
|
|
||||||
|
If you check `count1 == 0` first, you might reassign `candidate1` to an element that should have been counted under `candidate2`, corrupting your counts.
|
||||||
|
wrong_approach: "Checking for empty slots before checking for matches"
|
||||||
|
correct_approach: "Check matches first, then check for empty slots"
|
||||||
|
|
||||||
|
- title: Not Handling Duplicate Candidates
|
||||||
|
description: |
|
||||||
|
When assigning the second candidate, ensure it's different from the first candidate. If both slots hold the same value, you're effectively only tracking one element.
|
||||||
|
|
||||||
|
When `count2 == 0` and you adopt a new candidate, verify it's not equal to `candidate1`.
|
||||||
|
wrong_approach: "Allowing candidate1 and candidate2 to hold the same value"
|
||||||
|
correct_approach: "Ensure candidates are always distinct"
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Mathematical bound**: At most `k-1` elements can appear more than `n/k` times — this generalises Boyer-Moore"
|
||||||
|
- "**Verification is essential**: Unlike guaranteed-majority problems, always verify candidates when existence isn't guaranteed"
|
||||||
|
- "**Order of operations matters**: Check existing candidates before checking for empty slots"
|
||||||
|
- "**Foundation for generalisations**: The same technique extends to finding elements appearing more than `n/4`, `n/5`, etc., by tracking more candidate slots"
|
||||||
|
|
||||||
|
time_complexity: "O(n). We make two passes through the array — one for candidate selection, one for verification."
|
||||||
|
space_complexity: "O(1). We only use a fixed number of variables (two candidates, two counts) regardless of input size."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Extended Boyer-Moore Voting
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def majority_element(nums: list[int]) -> list[int]:
|
||||||
|
# At most 2 elements can appear more than n/3 times
|
||||||
|
candidate1, candidate2 = None, None
|
||||||
|
count1, count2 = 0, 0
|
||||||
|
|
||||||
|
# First pass: find potential candidates
|
||||||
|
for num in nums:
|
||||||
|
# Check matches first (order matters!)
|
||||||
|
if candidate1 == num:
|
||||||
|
count1 += 1
|
||||||
|
elif candidate2 == num:
|
||||||
|
count2 += 1
|
||||||
|
# Then check for empty slots
|
||||||
|
elif count1 == 0:
|
||||||
|
candidate1 = num
|
||||||
|
count1 = 1
|
||||||
|
elif count2 == 0:
|
||||||
|
candidate2 = num
|
||||||
|
count2 = 1
|
||||||
|
# Three distinct elements: cancel one from each
|
||||||
|
else:
|
||||||
|
count1 -= 1
|
||||||
|
count2 -= 1
|
||||||
|
|
||||||
|
# Second pass: verify candidates actually exceed threshold
|
||||||
|
threshold = len(nums) // 3
|
||||||
|
result = []
|
||||||
|
|
||||||
|
# Count actual occurrences
|
||||||
|
count1 = sum(1 for num in nums if num == candidate1)
|
||||||
|
count2 = sum(1 for num in nums if num == candidate2)
|
||||||
|
|
||||||
|
# Only include if they exceed n/3
|
||||||
|
if count1 > threshold:
|
||||||
|
result.append(candidate1)
|
||||||
|
if candidate2 != candidate1 and count2 > threshold:
|
||||||
|
result.append(candidate2)
|
||||||
|
|
||||||
|
return result
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — Two passes through the array.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — Only a fixed number of variables used.
|
||||||
|
|
||||||
|
The algorithm extends Boyer-Moore to track two candidates. The key insight is that at most two elements can exceed the `n/3` threshold. When three distinct elements are seen, they cancel each other out. A verification pass confirms which candidates actually qualify.
|
||||||
|
|
||||||
|
- approach_name: Hash Map Counting
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
def majority_element(nums: list[int]) -> list[int]:
|
||||||
|
# Count occurrences of each element
|
||||||
|
counts = Counter(nums)
|
||||||
|
threshold = len(nums) // 3
|
||||||
|
|
||||||
|
# Return all elements exceeding the threshold
|
||||||
|
return [num for num, count in counts.items() if count > threshold]
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — Single pass to build the counter.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n) — Hash map stores up to n distinct elements.
|
||||||
|
|
||||||
|
This approach is intuitive and easy to implement. It counts all elements and filters by the threshold. While correct, it uses more space than the optimal Boyer-Moore solution.
|
||||||
195
backend/data/questions/majority-element.yaml
Normal file
195
backend/data/questions/majority-element.yaml
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
title: Majority Element
|
||||||
|
slug: majority-element
|
||||||
|
difficulty: easy
|
||||||
|
leetcode_id: 169
|
||||||
|
leetcode_url: https://leetcode.com/problems/majority-element/
|
||||||
|
categories:
|
||||||
|
- arrays
|
||||||
|
- hash-tables
|
||||||
|
patterns:
|
||||||
|
- greedy
|
||||||
|
|
||||||
|
function_signature: "def majority_element(nums: list[int]) -> int:"
|
||||||
|
|
||||||
|
test_cases:
|
||||||
|
visible:
|
||||||
|
- input: { nums: [3, 2, 3] }
|
||||||
|
expected: 3
|
||||||
|
- input: { nums: [2, 2, 1, 1, 1, 2, 2] }
|
||||||
|
expected: 2
|
||||||
|
- input: { nums: [1] }
|
||||||
|
expected: 1
|
||||||
|
hidden:
|
||||||
|
- input: { nums: [6, 5, 5] }
|
||||||
|
expected: 5
|
||||||
|
- input: { nums: [1, 1, 1, 2, 2, 2, 2] }
|
||||||
|
expected: 2
|
||||||
|
- input: { nums: [3, 3, 4] }
|
||||||
|
expected: 3
|
||||||
|
|
||||||
|
description: |
|
||||||
|
Given an array `nums` of size `n`, return *the majority element*.
|
||||||
|
|
||||||
|
The majority element is the element that appears **more than** `⌊n / 2⌋` times. You may assume that the majority element always exists in the array.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `n == nums.length`
|
||||||
|
- `1 <= n <= 5 * 10^4`
|
||||||
|
- `-10^9 <= nums[i] <= 10^9`
|
||||||
|
- The input is generated such that a majority element will exist in the array
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: "nums = [3,2,3]"
|
||||||
|
output: "3"
|
||||||
|
explanation: "The element 3 appears twice out of 3 elements, which is more than ⌊3/2⌋ = 1 time."
|
||||||
|
- input: "nums = [2,2,1,1,1,2,2]"
|
||||||
|
output: "2"
|
||||||
|
explanation: "The element 2 appears 4 times out of 7 elements, which is more than ⌊7/2⌋ = 3 times."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Imagine a rowdy crowd where two groups are shouting different slogans. If one group has **more than half** the people, their voice will always dominate — no matter how the other groups combine.
|
||||||
|
|
||||||
|
This is the core insight behind the **Boyer-Moore Voting Algorithm**. Think of it as a "battle royale" where each element fights against others:
|
||||||
|
|
||||||
|
- When you encounter the same element, it gains strength (count increases)
|
||||||
|
- When you encounter a different element, they cancel each other out (count decreases)
|
||||||
|
|
||||||
|
Since the majority element appears more than `n/2` times, it's guaranteed to have "survivors" at the end. Even if every other element teams up against it, they can't outnumber it — the majority element will always be the last one standing.
|
||||||
|
|
||||||
|
The key insight is that if we pair up different elements to "eliminate" each other, the majority element will always have at least one unpaired instance remaining.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using the **Boyer-Moore Voting Algorithm**:
|
||||||
|
|
||||||
|
**Step 1: Initialise candidate tracking**
|
||||||
|
|
||||||
|
- `candidate`: Will store our current guess for the majority element
|
||||||
|
- `count`: Set to `0`, tracks the "strength" of our current candidate
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: First pass — find the candidate**
|
||||||
|
|
||||||
|
- For each element in the array:
|
||||||
|
- If `count == 0`, adopt the current element as our new `candidate`
|
||||||
|
- If the current element equals `candidate`, increment `count` (gains strength)
|
||||||
|
- Otherwise, decrement `count` (different elements cancel out)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Return the candidate**
|
||||||
|
|
||||||
|
- Since the problem guarantees a majority element exists, our candidate is the answer
|
||||||
|
- No verification pass is needed (but would be required if existence wasn't guaranteed)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
This works because the majority element, appearing more than half the time, cannot be fully cancelled out by all other elements combined.
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Using Extra Space with Hash Map
|
||||||
|
description: |
|
||||||
|
A common first approach is to count occurrences using a hash map:
|
||||||
|
- Iterate through the array, counting each element
|
||||||
|
- Return the element with count > `n/2`
|
||||||
|
|
||||||
|
While this works and runs in O(n) time, it uses **O(n) space** for the hash map. The follow-up specifically asks for O(1) space, which the Boyer-Moore algorithm achieves.
|
||||||
|
wrong_approach: "Hash map counting with O(n) space"
|
||||||
|
correct_approach: "Boyer-Moore Voting Algorithm with O(1) space"
|
||||||
|
|
||||||
|
- title: Sorting and Taking the Middle
|
||||||
|
description: |
|
||||||
|
Another approach is to sort the array and return the middle element. Since the majority element appears more than `n/2` times, it must occupy the middle position after sorting.
|
||||||
|
|
||||||
|
This works but has **O(n log n)** time complexity due to sorting. The Boyer-Moore algorithm achieves O(n) time.
|
||||||
|
wrong_approach: "Sorting with O(n log n) time"
|
||||||
|
correct_approach: "Single pass with O(n) time"
|
||||||
|
|
||||||
|
- title: Forgetting to Reset the Candidate
|
||||||
|
description: |
|
||||||
|
A critical part of Boyer-Moore is resetting the candidate when `count` reaches zero. If you only decrement without adopting a new candidate, you'll miss the majority element.
|
||||||
|
|
||||||
|
When `count == 0`, it means all previously seen elements have cancelled out, so we start fresh with the current element as our new candidate.
|
||||||
|
wrong_approach: "Only incrementing/decrementing without resetting candidate"
|
||||||
|
correct_approach: "Reset candidate when count becomes zero"
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Boyer-Moore Voting Algorithm**: A brilliant technique for finding majority elements in O(n) time and O(1) space"
|
||||||
|
- "**Cancellation principle**: Different elements cancel each other out; the majority survives because it can't be fully cancelled"
|
||||||
|
- "**Space-time optimisation**: When a hash map solution exists, ask if there's a pattern-based approach using constant space"
|
||||||
|
- "**Foundation for variations**: This extends to finding elements appearing more than `n/3` times (Boyer-Moore generalisation)"
|
||||||
|
|
||||||
|
time_complexity: "O(n). We traverse the array exactly once, performing constant-time operations at each step."
|
||||||
|
space_complexity: "O(1). We only use two variables (`candidate` and `count`), regardless of input size."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Boyer-Moore Voting Algorithm
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def majority_element(nums: list[int]) -> int:
|
||||||
|
# Current candidate for majority element
|
||||||
|
candidate = None
|
||||||
|
# Count tracks the "strength" of our candidate
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
for num in nums:
|
||||||
|
# If count is zero, adopt current element as new candidate
|
||||||
|
if count == 0:
|
||||||
|
candidate = num
|
||||||
|
|
||||||
|
# Same as candidate? Gains strength. Different? Cancel out.
|
||||||
|
if num == candidate:
|
||||||
|
count += 1
|
||||||
|
else:
|
||||||
|
count -= 1
|
||||||
|
|
||||||
|
# Candidate is guaranteed to be the majority element
|
||||||
|
return candidate
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — Single pass through the array.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — Only two variables used regardless of input size.
|
||||||
|
|
||||||
|
The algorithm works by maintaining a candidate and a count. When elements match, we increase confidence. When they differ, they cancel out. Since the majority element appears more than half the time, it will always be the survivor.
|
||||||
|
|
||||||
|
- approach_name: Hash Map Counting
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
def majority_element(nums: list[int]) -> int:
|
||||||
|
# Count occurrences of each element
|
||||||
|
counts = Counter(nums)
|
||||||
|
n = len(nums)
|
||||||
|
|
||||||
|
# Find the element appearing more than n/2 times
|
||||||
|
for num, count in counts.items():
|
||||||
|
if count > n // 2:
|
||||||
|
return num
|
||||||
|
|
||||||
|
# Problem guarantees majority exists, so we'll always return above
|
||||||
|
return -1
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — Single pass to build the counter.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n) — Hash map stores up to n/2 distinct elements in worst case.
|
||||||
|
|
||||||
|
This approach is intuitive and easy to implement. It counts all elements and returns the one exceeding the threshold. While correct, it uses more space than necessary.
|
||||||
|
|
||||||
|
- approach_name: Sorting
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def majority_element(nums: list[int]) -> int:
|
||||||
|
# Sort the array
|
||||||
|
nums.sort()
|
||||||
|
|
||||||
|
# The majority element must be at the middle index
|
||||||
|
# Since it appears > n/2 times, it spans the middle
|
||||||
|
return nums[len(nums) // 2]
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n log n) — Dominated by the sorting step.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) or O(n) — Depends on sorting algorithm (in-place vs. not).
|
||||||
|
|
||||||
|
After sorting, the majority element must occupy the middle position because it appears more than half the time. Simple but slower than optimal.
|
||||||
222
backend/data/questions/matchsticks-to-square.yaml
Normal file
222
backend/data/questions/matchsticks-to-square.yaml
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
title: Matchsticks to Square
|
||||||
|
slug: matchsticks-to-square
|
||||||
|
difficulty: medium
|
||||||
|
leetcode_id: 473
|
||||||
|
leetcode_url: https://leetcode.com/problems/matchsticks-to-square/
|
||||||
|
categories:
|
||||||
|
- arrays
|
||||||
|
- recursion
|
||||||
|
patterns:
|
||||||
|
- backtracking
|
||||||
|
|
||||||
|
description: |
|
||||||
|
You are given an integer array `matchsticks` where `matchsticks[i]` is the length of the i<sup>th</sup> matchstick. You want to use **all the matchsticks** to make one square. You **should not break** any stick, but you can link them up, and each matchstick must be used **exactly one time**.
|
||||||
|
|
||||||
|
Return `true` if you can make this square and `false` otherwise.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `1 <= matchsticks.length <= 15`
|
||||||
|
- `1 <= matchsticks[i] <= 10^8`
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: "matchsticks = [1,1,2,2,2]"
|
||||||
|
output: "true"
|
||||||
|
explanation: "You can form a square with length 2. One side of the square comes from two sticks with length 1."
|
||||||
|
- input: "matchsticks = [3,3,3,3,4]"
|
||||||
|
output: "false"
|
||||||
|
explanation: "You cannot find a way to form a square with all the matchsticks."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Imagine you have a pile of matchsticks on a table, and you need to arrange them into four groups — one for each side of a square. Every matchstick must be used exactly once, and all four sides must have the **same total length**.
|
||||||
|
|
||||||
|
The first insight is mathematical: for a square to be possible, the total length of all matchsticks must be **divisible by 4**. If it isn't, there's no way to form four equal sides, so we can immediately return `false`.
|
||||||
|
|
||||||
|
The second insight is about problem structure: this is a **partition problem**. We need to partition the array into 4 subsets where each subset sums to `total_length / 4`. This is a classic backtracking scenario — we try placing each matchstick into one of the four sides, and if we hit a dead end, we backtrack and try a different placement.
|
||||||
|
|
||||||
|
Think of it like this: you pick up each matchstick and ask, "Which side should this go on?" You try the first side, and if you can eventually complete the square, great! If not, you take the matchstick back and try another side. This systematic exploration guarantees we find a solution if one exists.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using **Backtracking with Pruning**:
|
||||||
|
|
||||||
|
**Step 1: Check if a square is possible**
|
||||||
|
|
||||||
|
- Calculate the total sum of all matchsticks
|
||||||
|
- If `total % 4 != 0`, return `false` immediately — a square is impossible
|
||||||
|
- Calculate `side_length = total // 4` — this is what each side must sum to
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Sort matchsticks in descending order (optimisation)**
|
||||||
|
|
||||||
|
- Sorting largest-first helps us fail faster
|
||||||
|
- Large matchsticks are harder to place, so if they can't fit, we discover this early
|
||||||
|
- This dramatically reduces the search space in practice
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Use backtracking to fill four sides**
|
||||||
|
|
||||||
|
- Create an array `sides = [0, 0, 0, 0]` to track the current length of each side
|
||||||
|
- For each matchstick, try adding it to each of the four sides
|
||||||
|
- If adding would exceed `side_length`, skip that side (pruning)
|
||||||
|
- If we successfully place all matchsticks, return `true`
|
||||||
|
- If no valid placement exists, backtrack by removing the matchstick and trying the next side
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 4: Additional pruning**
|
||||||
|
|
||||||
|
- Skip duplicate side values: if `sides[i] == sides[i-1]` and we already failed with `sides[i-1]`, skip `sides[i]`
|
||||||
|
- Early termination: if the first matchstick is larger than `side_length`, return `false`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 5: Return the result**
|
||||||
|
|
||||||
|
- If the backtracking completes successfully (all matchsticks placed), return `true`
|
||||||
|
- If we exhaust all possibilities, return `false`
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Forgetting the Divisibility Check
|
||||||
|
description: |
|
||||||
|
If the total sum of matchsticks isn't divisible by 4, no valid square exists. Always check this first.
|
||||||
|
|
||||||
|
For example, with `matchsticks = [1, 2, 3]`, the sum is `6`. Since `6 % 4 = 2 != 0`, we can immediately return `false` without any backtracking.
|
||||||
|
|
||||||
|
This simple check can save enormous computation time.
|
||||||
|
wrong_approach: "Start backtracking without checking divisibility"
|
||||||
|
correct_approach: "Return false early if total % 4 != 0"
|
||||||
|
|
||||||
|
- title: Not Sorting for Efficiency
|
||||||
|
description: |
|
||||||
|
Without sorting, you might try many small matchsticks first, build up partial sides, then discover a large matchstick doesn't fit anywhere. All that work is wasted.
|
||||||
|
|
||||||
|
By sorting in **descending order**, you try placing the largest (most constrained) matchsticks first. If they can't fit, you fail fast and prune massive branches of the search tree.
|
||||||
|
|
||||||
|
With `n = 15` matchsticks and `4^15` potential placements, pruning is essential.
|
||||||
|
wrong_approach: "Process matchsticks in original order"
|
||||||
|
correct_approach: "Sort descending to fail fast on large matchsticks"
|
||||||
|
|
||||||
|
- title: Treating This as a Simple DP Problem
|
||||||
|
description: |
|
||||||
|
While dynamic programming with bitmasks can solve this (tracking which matchsticks are used), the state space is `2^n * 4` which is manageable for `n <= 15`.
|
||||||
|
|
||||||
|
However, backtracking with good pruning is often simpler to implement and understand. Both approaches work, but backtracking with descending sort is typically faster in practice due to aggressive early termination.
|
||||||
|
wrong_approach: "Overcomplicating with bitmask DP when backtracking suffices"
|
||||||
|
correct_approach: "Use backtracking with sorting and pruning"
|
||||||
|
|
||||||
|
- title: Missing Duplicate Side Pruning
|
||||||
|
description: |
|
||||||
|
If two sides have the same current length and we tried (and failed) adding a matchstick to one, there's no point trying the other — the result will be identical.
|
||||||
|
|
||||||
|
For example, if `sides = [3, 3, 0, 0]` and adding the current matchstick to `sides[0]` leads to failure, adding it to `sides[1]` will fail the same way.
|
||||||
|
|
||||||
|
Skipping these duplicate attempts significantly reduces redundant work.
|
||||||
|
wrong_approach: "Try every side even when some have identical values"
|
||||||
|
correct_approach: "Skip sides with the same value as a previously failed attempt"
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Partition problems and backtracking**: When you need to divide elements into groups with constraints, backtracking systematically explores all possibilities"
|
||||||
|
- "**Pruning is essential**: Without optimisations like sorting and duplicate skipping, backtracking can be impractically slow"
|
||||||
|
- "**Early termination**: Simple checks (divisibility, single element too large) can eliminate entire problem instances instantly"
|
||||||
|
- "**Related problems**: This pattern appears in Partition Equal Subset Sum, Partition to K Equal Sum Subsets, and Fair Distribution of Cookies"
|
||||||
|
|
||||||
|
time_complexity: "O(4^n) in the worst case, where `n` is the number of matchsticks. Each matchstick can potentially go into any of the 4 sides. However, pruning (sorting, duplicate skipping, sum checks) dramatically reduces this in practice."
|
||||||
|
space_complexity: "O(n) for the recursion call stack depth, plus O(1) for the sides array."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Backtracking with Pruning
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def makesquare(matchsticks: list[int]) -> bool:
|
||||||
|
total = sum(matchsticks)
|
||||||
|
|
||||||
|
# A square needs 4 equal sides
|
||||||
|
if total % 4 != 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
side_length = total // 4
|
||||||
|
|
||||||
|
# Sort descending to try large matchsticks first (fail fast)
|
||||||
|
matchsticks.sort(reverse=True)
|
||||||
|
|
||||||
|
# Early check: if largest matchstick > side_length, impossible
|
||||||
|
if matchsticks[0] > side_length:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Track current length of each of the 4 sides
|
||||||
|
sides = [0, 0, 0, 0]
|
||||||
|
|
||||||
|
def backtrack(index: int) -> bool:
|
||||||
|
# Base case: all matchsticks placed successfully
|
||||||
|
if index == len(matchsticks):
|
||||||
|
return True
|
||||||
|
|
||||||
|
stick = matchsticks[index]
|
||||||
|
|
||||||
|
# Try placing this matchstick on each side
|
||||||
|
for i in range(4):
|
||||||
|
# Pruning: skip if this side would exceed target
|
||||||
|
if sides[i] + stick > side_length:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Pruning: skip duplicate sides (same current length)
|
||||||
|
if i > 0 and sides[i] == sides[i - 1]:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Place the matchstick
|
||||||
|
sides[i] += stick
|
||||||
|
|
||||||
|
# Recurse to place the next matchstick
|
||||||
|
if backtrack(index + 1):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Backtrack: remove the matchstick
|
||||||
|
sides[i] -= stick
|
||||||
|
|
||||||
|
# No valid placement found for this matchstick
|
||||||
|
return False
|
||||||
|
|
||||||
|
return backtrack(0)
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(4^n) worst case — each matchstick can go into 4 sides. Pruning reduces this significantly in practice.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n) — recursion depth equals the number of matchsticks.
|
||||||
|
|
||||||
|
We try placing each matchstick into one of four sides. Sorting largest-first means we quickly discover when a large matchstick can't fit anywhere. Skipping duplicate side values avoids redundant exploration. If we place all matchsticks without any side exceeding the target length, we've found a valid square.
|
||||||
|
|
||||||
|
- approach_name: Brute Force (No Pruning)
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def makesquare(matchsticks: list[int]) -> bool:
|
||||||
|
total = sum(matchsticks)
|
||||||
|
if total % 4 != 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
side_length = total // 4
|
||||||
|
sides = [0, 0, 0, 0]
|
||||||
|
|
||||||
|
def backtrack(index: int) -> bool:
|
||||||
|
if index == len(matchsticks):
|
||||||
|
# Check if all sides equal
|
||||||
|
return all(s == side_length for s in sides)
|
||||||
|
|
||||||
|
stick = matchsticks[index]
|
||||||
|
|
||||||
|
for i in range(4):
|
||||||
|
if sides[i] + stick <= side_length:
|
||||||
|
sides[i] += stick
|
||||||
|
if backtrack(index + 1):
|
||||||
|
return True
|
||||||
|
sides[i] -= stick
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
return backtrack(0)
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(4^n) — without pruning, explores many redundant branches.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n) — recursion depth.
|
||||||
|
|
||||||
|
This version works but lacks the critical optimisations. Without sorting, it may build partial solutions with small matchsticks only to fail later on large ones. Without duplicate pruning, it explores identical configurations multiple times. For the constraint `n <= 15`, this can be noticeably slower than the optimised version.
|
||||||
268
backend/data/questions/max-area-of-island.yaml
Normal file
268
backend/data/questions/max-area-of-island.yaml
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
title: Max Area of Island
|
||||||
|
slug: max-area-of-island
|
||||||
|
difficulty: medium
|
||||||
|
leetcode_id: 695
|
||||||
|
leetcode_url: https://leetcode.com/problems/max-area-of-island/
|
||||||
|
categories:
|
||||||
|
- graphs
|
||||||
|
- arrays
|
||||||
|
patterns:
|
||||||
|
- dfs
|
||||||
|
- bfs
|
||||||
|
- matrix-traversal
|
||||||
|
|
||||||
|
description: |
|
||||||
|
You are given an `m × n` binary matrix `grid`. An island is a group of `1`s (representing land) connected **4-directionally** (horizontal or vertical). You may assume all four edges of the grid are surrounded by water.
|
||||||
|
|
||||||
|
The **area** of an island is the number of cells with a value `1` in the island.
|
||||||
|
|
||||||
|
Return *the maximum **area** of an island in* `grid`. If there is no island, return `0`.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `m == grid.length`
|
||||||
|
- `n == grid[i].length`
|
||||||
|
- `1 <= m, n <= 50`
|
||||||
|
- `grid[i][j]` is either `0` or `1`
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: |
|
||||||
|
grid = [
|
||||||
|
[0,0,1,0,0,0,0,1,0,0,0,0,0],
|
||||||
|
[0,0,0,0,0,0,0,1,1,1,0,0,0],
|
||||||
|
[0,1,1,0,1,0,0,0,0,0,0,0,0],
|
||||||
|
[0,1,0,0,1,1,0,0,1,0,1,0,0],
|
||||||
|
[0,1,0,0,1,1,0,0,1,1,1,0,0],
|
||||||
|
[0,0,0,0,0,0,0,0,0,0,1,0,0],
|
||||||
|
[0,0,0,0,0,0,0,1,1,1,0,0,0],
|
||||||
|
[0,0,0,0,0,0,0,1,1,0,0,0,0]
|
||||||
|
]
|
||||||
|
output: "6"
|
||||||
|
explanation: "The answer is not 11, because islands must be connected 4-directionally (not diagonally). The largest island has 6 cells."
|
||||||
|
- input: "grid = [[0,0,0,0,0,0,0,0]]"
|
||||||
|
output: "0"
|
||||||
|
explanation: "There is no land, so the maximum area is 0."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
This problem builds directly on "Number of Islands". Instead of counting how many islands exist, we want to find the **largest** one.
|
||||||
|
|
||||||
|
Imagine you're a cartographer mapping islands from above. When you spot a piece of land, you explore the entire island by walking to all connected land cells (up, down, left, right — no diagonals). As you explore, you count each cell you visit. After exploring the whole island, you record its area.
|
||||||
|
|
||||||
|
Think of it like this: each time you discover a new island, you measure its size by counting cells during your exploration. You keep track of the largest island you've found so far. When you're done scanning the entire map, the largest recorded measurement is your answer.
|
||||||
|
|
||||||
|
The key insight is that **DFS/BFS naturally counts area** — every cell we visit during an exploration is part of that island, so we just need to count how many cells we visit per island.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using **DFS with Area Counting**:
|
||||||
|
|
||||||
|
**Step 1: Set up tracking variables**
|
||||||
|
|
||||||
|
- `max_area`: Set to `0` to track the largest island found
|
||||||
|
- `rows`, `cols`: Store grid dimensions for bounds checking
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Iterate through every cell**
|
||||||
|
|
||||||
|
- Scan the grid row by row, column by column
|
||||||
|
- When we find a `1` (unvisited land), we've found a new island to explore
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: DFS exploration with counting**
|
||||||
|
|
||||||
|
- When visiting a land cell, mark it as `0` (visited) to prevent revisiting
|
||||||
|
- Count this cell as `1` area
|
||||||
|
- Recursively explore all four directions (up, down, left, right)
|
||||||
|
- Return the sum: `1 + area_from_up + area_from_down + area_from_left + area_from_right`
|
||||||
|
- Base case: return `0` if out of bounds or cell is water
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 4: Track the maximum**
|
||||||
|
|
||||||
|
- After each DFS completes, compare the island's area to `max_area`
|
||||||
|
- Update `max_area` if this island is larger
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 5: Return the result**
|
||||||
|
|
||||||
|
- After processing all cells, return `max_area`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
This works because DFS visits every cell of an island exactly once, and by returning `1 + sum_of_neighbors`, we recursively accumulate the total area.
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Forgetting to Return Area from DFS
|
||||||
|
description: |
|
||||||
|
A common mistake is to use a global or external variable to track area, but forget to properly accumulate it from recursive calls.
|
||||||
|
|
||||||
|
The cleanest approach is to have DFS return the area it explored:
|
||||||
|
- Base case returns `0` (water or out of bounds contributes no area)
|
||||||
|
- Recursive case returns `1 + sum of areas from all four directions`
|
||||||
|
|
||||||
|
This naturally accumulates the total area through the call stack.
|
||||||
|
wrong_approach: "Using external counter without proper accumulation"
|
||||||
|
correct_approach: "Return area from DFS: return 1 + dfs(up) + dfs(down) + dfs(left) + dfs(right)"
|
||||||
|
|
||||||
|
- title: Not Marking Cells Before Recursion
|
||||||
|
description: |
|
||||||
|
You must mark a cell as visited (set to `0`) **before** making recursive calls. Otherwise, when exploring neighbors, they might try to revisit the current cell, causing infinite recursion.
|
||||||
|
|
||||||
|
For example, if cell A explores cell B, and we haven't marked A as visited yet, then B will try to explore A, which explores B again — infinite loop!
|
||||||
|
wrong_approach: "Marking cell after recursive calls"
|
||||||
|
correct_approach: "grid[r][c] = 0 immediately upon entering the cell"
|
||||||
|
|
||||||
|
- title: Including Diagonal Connections
|
||||||
|
description: |
|
||||||
|
The problem specifies **4-directional** connectivity. Cells connected diagonally are NOT part of the same island.
|
||||||
|
|
||||||
|
Only explore the 4 orthogonal directions: up, down, left, right. This is a common source of wrong answers.
|
||||||
|
wrong_approach: "Exploring 8 directions including diagonals"
|
||||||
|
correct_approach: "Explore only (r±1, c) and (r, c±1)"
|
||||||
|
|
||||||
|
- title: Returning Wrong Value for Empty Grid
|
||||||
|
description: |
|
||||||
|
If the grid contains no land at all, the answer should be `0`. Initialising `max_area = 0` handles this automatically — if no island is ever found, `0` is returned.
|
||||||
|
|
||||||
|
Don't initialise `max_area` to `-1` or forget to handle the all-water case.
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**DFS returns values**: Making DFS return the area it explores leads to clean, recursive accumulation"
|
||||||
|
- "**Build on simpler problems**: Max Area of Island is Number of Islands with one addition — counting during traversal"
|
||||||
|
- "**In-place marking**: Modifying the grid to track visited cells avoids extra space for a visited set"
|
||||||
|
- "**Pattern recognition**: Many grid problems use DFS/BFS to explore connected components; the only difference is what you track during exploration"
|
||||||
|
|
||||||
|
time_complexity: "O(m × n). Each cell is visited at most once by the main loop and at most once by DFS."
|
||||||
|
space_complexity: "O(m × n). In the worst case (all land in a snake pattern), the DFS recursion stack can hold all cells."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: DFS with Return Value
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def max_area_of_island(grid: list[list[int]]) -> int:
|
||||||
|
if not grid:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
rows, cols = len(grid), len(grid[0])
|
||||||
|
max_area = 0
|
||||||
|
|
||||||
|
def dfs(r: int, c: int) -> int:
|
||||||
|
# Base case: out of bounds or water
|
||||||
|
if r < 0 or r >= rows or c < 0 or c >= cols:
|
||||||
|
return 0
|
||||||
|
if grid[r][c] != 1:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Mark as visited by "sinking" the land
|
||||||
|
grid[r][c] = 0
|
||||||
|
|
||||||
|
# Count this cell (1) plus all connected cells
|
||||||
|
return (1 +
|
||||||
|
dfs(r + 1, c) + # down
|
||||||
|
dfs(r - 1, c) + # up
|
||||||
|
dfs(r, c + 1) + # right
|
||||||
|
dfs(r, c - 1)) # left
|
||||||
|
|
||||||
|
# Scan every cell in the grid
|
||||||
|
for r in range(rows):
|
||||||
|
for c in range(cols):
|
||||||
|
if grid[r][c] == 1:
|
||||||
|
# Found new island — measure its area
|
||||||
|
area = dfs(r, c)
|
||||||
|
max_area = max(max_area, area)
|
||||||
|
|
||||||
|
return max_area
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(m × n) — Each cell visited at most twice (once by loop, once by DFS).
|
||||||
|
|
||||||
|
**Space Complexity:** O(m × n) — Recursion stack in worst case (grid is all land in a snake pattern).
|
||||||
|
|
||||||
|
The DFS function returns the area it explores. When we find unvisited land, we call DFS which returns the total area of that island. We track the maximum area seen across all islands.
|
||||||
|
|
||||||
|
- approach_name: BFS with Queue
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
def max_area_of_island(grid: list[list[int]]) -> int:
|
||||||
|
if not grid:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
rows, cols = len(grid), len(grid[0])
|
||||||
|
max_area = 0
|
||||||
|
|
||||||
|
def bfs(start_r: int, start_c: int) -> int:
|
||||||
|
queue = deque([(start_r, start_c)])
|
||||||
|
grid[start_r][start_c] = 0 # Mark starting cell
|
||||||
|
area = 0
|
||||||
|
|
||||||
|
while queue:
|
||||||
|
r, c = queue.popleft()
|
||||||
|
area += 1 # Count this cell
|
||||||
|
|
||||||
|
# Explore all four directions
|
||||||
|
for dr, dc in [(1, 0), (-1, 0), (0, 1), (0, -1)]:
|
||||||
|
nr, nc = r + dr, c + dc
|
||||||
|
|
||||||
|
# Add unvisited land to queue
|
||||||
|
if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == 1:
|
||||||
|
grid[nr][nc] = 0 # Mark before adding
|
||||||
|
queue.append((nr, nc))
|
||||||
|
|
||||||
|
return area
|
||||||
|
|
||||||
|
for r in range(rows):
|
||||||
|
for c in range(cols):
|
||||||
|
if grid[r][c] == 1:
|
||||||
|
area = bfs(r, c)
|
||||||
|
max_area = max(max_area, area)
|
||||||
|
|
||||||
|
return max_area
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(m × n) — Same as DFS.
|
||||||
|
|
||||||
|
**Space Complexity:** O(min(m, n)) — Queue holds at most one "frontier" layer.
|
||||||
|
|
||||||
|
BFS explores level by level. We count area by incrementing a counter each time we process a cell from the queue. Mark cells when adding to queue (not when processing) to avoid duplicates.
|
||||||
|
|
||||||
|
- approach_name: Iterative DFS with Stack
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def max_area_of_island(grid: list[list[int]]) -> int:
|
||||||
|
if not grid:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
rows, cols = len(grid), len(grid[0])
|
||||||
|
max_area = 0
|
||||||
|
|
||||||
|
for r in range(rows):
|
||||||
|
for c in range(cols):
|
||||||
|
if grid[r][c] == 1:
|
||||||
|
# Use stack for iterative DFS
|
||||||
|
stack = [(r, c)]
|
||||||
|
grid[r][c] = 0
|
||||||
|
area = 0
|
||||||
|
|
||||||
|
while stack:
|
||||||
|
cr, cc = stack.pop()
|
||||||
|
area += 1
|
||||||
|
|
||||||
|
# Explore all four directions
|
||||||
|
for dr, dc in [(1, 0), (-1, 0), (0, 1), (0, -1)]:
|
||||||
|
nr, nc = cr + dr, cc + dc
|
||||||
|
if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == 1:
|
||||||
|
grid[nr][nc] = 0
|
||||||
|
stack.append((nr, nc))
|
||||||
|
|
||||||
|
max_area = max(max_area, area)
|
||||||
|
|
||||||
|
return max_area
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(m × n) — Same as recursive DFS.
|
||||||
|
|
||||||
|
**Space Complexity:** O(m × n) — Stack can hold all cells in worst case.
|
||||||
|
|
||||||
|
This avoids recursion by using an explicit stack. Useful in languages with limited recursion depth or when you prefer iterative solutions. The logic mirrors recursive DFS but manages the stack manually.
|
||||||
224
backend/data/questions/maximum-depth-of-binary-tree.yaml
Normal file
224
backend/data/questions/maximum-depth-of-binary-tree.yaml
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
title: Maximum Depth of Binary Tree
|
||||||
|
slug: maximum-depth-of-binary-tree
|
||||||
|
difficulty: easy
|
||||||
|
leetcode_id: 104
|
||||||
|
leetcode_url: https://leetcode.com/problems/maximum-depth-of-binary-tree/
|
||||||
|
categories:
|
||||||
|
- trees
|
||||||
|
- recursion
|
||||||
|
patterns:
|
||||||
|
- dfs
|
||||||
|
- bfs
|
||||||
|
- tree-traversal
|
||||||
|
|
||||||
|
description: |
|
||||||
|
Given the `root` of a binary tree, return *its maximum depth*.
|
||||||
|
|
||||||
|
A binary tree's **maximum depth** is the number of nodes along the longest path from the root node down to the farthest leaf node.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `0 <= number of nodes <= 10^4`
|
||||||
|
- `-100 <= Node.val <= 100`
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: "root = [3,9,20,null,null,15,7]"
|
||||||
|
output: "3"
|
||||||
|
explanation: "The tree has three levels: root (3), second level (9, 20), and third level (15, 7). The longest path has 3 nodes."
|
||||||
|
- input: "root = [1,null,2]"
|
||||||
|
output: "2"
|
||||||
|
explanation: "The tree has two levels: root (1) and its right child (2)."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Think of a binary tree like a family tree where you want to find your most distant descendant.
|
||||||
|
|
||||||
|
The **maximum depth** is simply how many generations deep your family tree goes. Starting from yourself (the root), you count: child, grandchild, great-grandchild... until you reach someone with no children (a leaf node).
|
||||||
|
|
||||||
|
Here's the key insight: the depth of any node equals **1 plus the maximum depth of its subtrees**. If you're at a node, your depth is one more than whichever of your children's subtrees is deeper. This naturally leads to a recursive solution.
|
||||||
|
|
||||||
|
Alternatively, think of it like exploring floors in a building. Using BFS, you visit each floor (level) one at a time and count how many floors you traverse before running out of rooms. The number of floors visited is the maximum depth.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We can solve this using either **DFS (recursive)** or **BFS (iterative)** approaches:
|
||||||
|
|
||||||
|
**DFS Recursive Approach**
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 1: Handle the base case**
|
||||||
|
|
||||||
|
- If the node is `None` (empty tree or reached past a leaf), return `0`
|
||||||
|
- This is our stopping condition for recursion
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Recursively find depths of subtrees**
|
||||||
|
|
||||||
|
- Calculate `left_depth`: recursively call on the left child
|
||||||
|
- Calculate `right_depth`: recursively call on the right child
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Combine results**
|
||||||
|
|
||||||
|
- Return `1 + max(left_depth, right_depth)`
|
||||||
|
- The `1` accounts for the current node
|
||||||
|
- We take the maximum because we want the longest path
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**BFS Iterative Approach**
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 1: Handle edge case**
|
||||||
|
|
||||||
|
- If root is `None`, return `0`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Initialize BFS**
|
||||||
|
|
||||||
|
- Create a queue and add the root node
|
||||||
|
- Initialize `depth = 0`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Process level by level**
|
||||||
|
|
||||||
|
- While the queue is not empty:
|
||||||
|
- Increment `depth` by 1 (we're processing a new level)
|
||||||
|
- Process all nodes at the current level
|
||||||
|
- Add their children to the queue for the next level
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 4: Return the depth**
|
||||||
|
|
||||||
|
- After processing all levels, `depth` holds the maximum depth
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Off-by-One Errors
|
||||||
|
description: |
|
||||||
|
A common mistake is forgetting to add `1` for the current node when combining subtree depths.
|
||||||
|
|
||||||
|
If left subtree has depth 2 and right has depth 1, the total depth isn't `max(2, 1) = 2`. It's `1 + max(2, 1) = 3` because you must count the current node too.
|
||||||
|
wrong_approach: "Return max(left_depth, right_depth)"
|
||||||
|
correct_approach: "Return 1 + max(left_depth, right_depth)"
|
||||||
|
|
||||||
|
- title: Not Handling Empty Trees
|
||||||
|
description: |
|
||||||
|
Forgetting to handle `root = None` will cause a `NoneType` error when trying to access `.left` or `.right`.
|
||||||
|
|
||||||
|
An empty tree has depth `0`, not `1`. The base case `if not root: return 0` must come first.
|
||||||
|
wrong_approach: "Assume root always exists"
|
||||||
|
correct_approach: "Check for None at the start of recursion"
|
||||||
|
|
||||||
|
- title: Confusing Depth vs Height
|
||||||
|
description: |
|
||||||
|
Some definitions count edges instead of nodes. LeetCode's definition counts **nodes** along the path.
|
||||||
|
|
||||||
|
A single-node tree has depth `1` (one node), not `0` (zero edges). Be consistent with the problem's definition.
|
||||||
|
wrong_approach: "Count edges between nodes"
|
||||||
|
correct_approach: "Count nodes along the path (LeetCode standard)"
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Recursive tree pattern**: Many tree problems follow the formula: handle base case, recurse on children, combine results"
|
||||||
|
- "**DFS vs BFS trade-off**: DFS uses O(h) stack space (h = height), BFS uses O(w) queue space (w = max width). For balanced trees, BFS may use more space"
|
||||||
|
- "**Foundation for harder problems**: This exact pattern extends to problems like balanced tree check, diameter of tree, and path sum"
|
||||||
|
- "**Two valid approaches**: Recognizing when both DFS and BFS can solve a problem helps you choose based on constraints"
|
||||||
|
|
||||||
|
time_complexity: "O(n). We visit every node in the tree exactly once."
|
||||||
|
space_complexity: "O(h) for DFS where h is the tree height (O(n) worst case for skewed tree, O(log n) for balanced). O(w) for BFS where w is the maximum width of the tree."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: DFS Recursive
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
class TreeNode:
|
||||||
|
def __init__(self, val=0, left=None, right=None):
|
||||||
|
self.val = val
|
||||||
|
self.left = left
|
||||||
|
self.right = right
|
||||||
|
|
||||||
|
def max_depth(root: TreeNode | None) -> int:
|
||||||
|
# Base case: empty tree has depth 0
|
||||||
|
if not root:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Recursively find depth of left and right subtrees
|
||||||
|
left_depth = max_depth(root.left)
|
||||||
|
right_depth = max_depth(root.right)
|
||||||
|
|
||||||
|
# Current depth = 1 (this node) + deeper subtree
|
||||||
|
return 1 + max(left_depth, right_depth)
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — Visit each node once.
|
||||||
|
|
||||||
|
**Space Complexity:** O(h) — Recursion stack depth equals tree height. Worst case O(n) for a skewed tree, O(log n) for a balanced tree.
|
||||||
|
|
||||||
|
This elegant recursive solution embodies the tree traversal pattern: solve for children, then combine. The base case handles empty subtrees, and the recursive case builds up the answer from the bottom.
|
||||||
|
|
||||||
|
- approach_name: BFS Level Order
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
def max_depth(root: TreeNode | None) -> int:
|
||||||
|
# Empty tree has depth 0
|
||||||
|
if not root:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
queue = deque([root])
|
||||||
|
depth = 0
|
||||||
|
|
||||||
|
while queue:
|
||||||
|
# Process all nodes at current level
|
||||||
|
depth += 1
|
||||||
|
level_size = len(queue)
|
||||||
|
|
||||||
|
for _ in range(level_size):
|
||||||
|
node = queue.popleft()
|
||||||
|
|
||||||
|
# Add children for next level
|
||||||
|
if node.left:
|
||||||
|
queue.append(node.left)
|
||||||
|
if node.right:
|
||||||
|
queue.append(node.right)
|
||||||
|
|
||||||
|
return depth
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — Visit each node once.
|
||||||
|
|
||||||
|
**Space Complexity:** O(w) — Queue holds at most one level. For a complete binary tree, the last level has ~n/2 nodes, so O(n) in the worst case.
|
||||||
|
|
||||||
|
BFS processes the tree level by level. Each iteration of the outer while loop processes one complete level, incrementing depth. When the queue empties, we've counted all levels.
|
||||||
|
|
||||||
|
- approach_name: DFS Iterative with Stack
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def max_depth(root: TreeNode | None) -> int:
|
||||||
|
if not root:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Stack stores (node, current_depth) pairs
|
||||||
|
stack = [(root, 1)]
|
||||||
|
max_depth_found = 0
|
||||||
|
|
||||||
|
while stack:
|
||||||
|
node, depth = stack.pop()
|
||||||
|
max_depth_found = max(max_depth_found, depth)
|
||||||
|
|
||||||
|
# Add children with incremented depth
|
||||||
|
if node.left:
|
||||||
|
stack.append((node.left, depth + 1))
|
||||||
|
if node.right:
|
||||||
|
stack.append((node.right, depth + 1))
|
||||||
|
|
||||||
|
return max_depth_found
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — Visit each node once.
|
||||||
|
|
||||||
|
**Space Complexity:** O(h) — Stack depth equals tree height.
|
||||||
|
|
||||||
|
This iterative DFS explicitly manages the stack that recursion handles implicitly. We track each node's depth as we traverse, updating the maximum when we visit each node. Useful when recursion depth might cause stack overflow.
|
||||||
210
backend/data/questions/maximum-frequency-stack.yaml
Normal file
210
backend/data/questions/maximum-frequency-stack.yaml
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
title: Maximum Frequency Stack
|
||||||
|
slug: maximum-frequency-stack
|
||||||
|
difficulty: hard
|
||||||
|
leetcode_id: 895
|
||||||
|
leetcode_url: https://leetcode.com/problems/maximum-frequency-stack/
|
||||||
|
categories:
|
||||||
|
- stack
|
||||||
|
- hash-tables
|
||||||
|
patterns:
|
||||||
|
- heap
|
||||||
|
|
||||||
|
description: |
|
||||||
|
Design a stack-like data structure to push elements to the stack and pop the most frequent element from the stack.
|
||||||
|
|
||||||
|
Implement the `FreqStack` class:
|
||||||
|
|
||||||
|
- `FreqStack()` constructs an empty frequency stack.
|
||||||
|
- `void push(int val)` pushes an integer `val` onto the top of the stack.
|
||||||
|
- `int pop()` removes and returns the most frequent element in the stack.
|
||||||
|
- If there is a tie for the most frequent element, the element closest to the stack's top is removed and returned.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `0 <= val <= 10^9`
|
||||||
|
- At most `2 * 10^4` calls will be made to `push` and `pop`.
|
||||||
|
- It is guaranteed that there will be at least one element in the stack before calling `pop`.
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: |
|
||||||
|
["FreqStack", "push", "push", "push", "push", "push", "push", "pop", "pop", "pop", "pop"]
|
||||||
|
[[], [5], [7], [5], [7], [4], [5], [], [], [], []]
|
||||||
|
output: "[null, null, null, null, null, null, null, 5, 7, 5, 4]"
|
||||||
|
explanation: |
|
||||||
|
FreqStack freqStack = new FreqStack();
|
||||||
|
freqStack.push(5); // The stack is [5]
|
||||||
|
freqStack.push(7); // The stack is [5,7]
|
||||||
|
freqStack.push(5); // The stack is [5,7,5]
|
||||||
|
freqStack.push(7); // The stack is [5,7,5,7]
|
||||||
|
freqStack.push(4); // The stack is [5,7,5,7,4]
|
||||||
|
freqStack.push(5); // The stack is [5,7,5,7,4,5]
|
||||||
|
freqStack.pop(); // return 5, as 5 is the most frequent. The stack becomes [5,7,5,7,4].
|
||||||
|
freqStack.pop(); // return 7, as 5 and 7 are the most frequent, but 7 is closest to the top. The stack becomes [5,7,5,4].
|
||||||
|
freqStack.pop(); // return 5, as 5 is the most frequent. The stack becomes [5,7,4].
|
||||||
|
freqStack.pop(); // return 4, as 4, 5 and 7 are the most frequent, but 4 is closest to the top. The stack becomes [5,7].
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Imagine you're managing a priority queue at a busy restaurant, but with a twist: instead of "first come, first served," you want to serve the customer who has visited most often. And if two customers have visited equally often, you serve the one who arrived most recently.
|
||||||
|
|
||||||
|
The key insight is to think about **frequency as a level**. Each time an element appears, it "graduates" to a higher frequency level. When we pop, we want the element at the highest frequency level, and if there are multiple elements at that level, we want the most recently pushed one.
|
||||||
|
|
||||||
|
Think of it like this: imagine having a **stack of stacks**, where each inner stack holds all elements that have reached a particular frequency. When you push `5` for the first time, it goes on the frequency-1 stack. Push `5` again, and a copy goes on the frequency-2 stack. The beauty is that popping from the highest frequency stack automatically gives you the most recent element at that frequency!
|
||||||
|
|
||||||
|
This "stack of stacks" model elegantly handles both requirements: maximum frequency (track the highest level) and recency (each level is a stack, so LIFO within that level).
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using **two hash maps and a stack-of-stacks structure**:
|
||||||
|
|
||||||
|
**Step 1: Design the data structures**
|
||||||
|
|
||||||
|
- `freq`: A hash map mapping each value to its current frequency count
|
||||||
|
- `group`: A hash map mapping each frequency level to a stack of values at that level
|
||||||
|
- `max_freq`: An integer tracking the current maximum frequency
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Implement push(val)**
|
||||||
|
|
||||||
|
- Increment the frequency of `val` in the `freq` map
|
||||||
|
- Get the new frequency `f` for this value
|
||||||
|
- Append `val` to the stack at `group[f]`
|
||||||
|
- Update `max_freq` if `f` exceeds it
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Implement pop()**
|
||||||
|
|
||||||
|
- Look at the stack at `group[max_freq]` — this contains all elements with the highest frequency
|
||||||
|
- Pop from this stack to get the most recent element at max frequency
|
||||||
|
- Decrement that element's frequency in the `freq` map
|
||||||
|
- If the stack at `max_freq` is now empty, decrement `max_freq`
|
||||||
|
- Return the popped element
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
The elegance of this approach is that we never need to remove elements from middle of structures — we only ever pop from the top of stacks, making all operations O(1).
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Using a Heap Instead of Stack-of-Stacks
|
||||||
|
description: |
|
||||||
|
A natural instinct is to use a max-heap keyed by (frequency, timestamp) to always get the most frequent, most recent element.
|
||||||
|
|
||||||
|
While this works, it results in **O(log n) operations** instead of O(1). The heap needs to rebalance on every push and pop. With up to `2 * 10^4` operations, this is acceptable but suboptimal.
|
||||||
|
|
||||||
|
The stack-of-stacks approach achieves O(1) by cleverly avoiding the need for sorting — stacks naturally maintain insertion order within each frequency level.
|
||||||
|
wrong_approach: "Max-heap with (frequency, timestamp) tuples"
|
||||||
|
correct_approach: "Stack-of-stacks grouped by frequency"
|
||||||
|
|
||||||
|
- title: Forgetting to Track max_freq
|
||||||
|
description: |
|
||||||
|
Without tracking `max_freq`, you'd need to scan through all frequency levels on each pop to find the highest non-empty stack.
|
||||||
|
|
||||||
|
This would degrade pop to O(n) in the worst case, where n is the number of distinct frequencies. By maintaining `max_freq` and decrementing it only when the top stack empties, we keep pop at O(1).
|
||||||
|
wrong_approach: "Scanning all frequency levels to find max"
|
||||||
|
correct_approach: "Track max_freq and decrement when needed"
|
||||||
|
|
||||||
|
- title: Removing Elements from the Frequency Map
|
||||||
|
description: |
|
||||||
|
When popping, don't remove the element from the `freq` map even if its frequency drops to zero. This creates unnecessary complexity.
|
||||||
|
|
||||||
|
Simply decrement the frequency. If the element is pushed again later, its count will increment from where it left off. The `group` stacks handle the "active" elements at each level.
|
||||||
|
wrong_approach: "Removing entries when frequency reaches zero"
|
||||||
|
correct_approach: "Just decrement frequency, let it reach zero"
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Stack-of-stacks pattern**: When you need both priority ordering and recency, consider grouping stacks by priority level"
|
||||||
|
- "**O(1) design**: The key insight is that we only ever interact with the *top* of stacks, avoiding expensive search or rebalancing operations"
|
||||||
|
- "**Frequency as a level**: This mental model — elements 'graduating' to higher frequency levels — helps visualise the structure"
|
||||||
|
- "**Design problem strategy**: Break down the requirements (max frequency + recency) and find a data structure that satisfies both simultaneously"
|
||||||
|
|
||||||
|
time_complexity: "O(1) for both `push` and `pop`. Each operation involves constant-time hash map lookups and stack operations."
|
||||||
|
space_complexity: "O(n) where n is the number of elements pushed. We store each element in the frequency map and potentially in multiple stacks (once per occurrence)."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Stack of Stacks
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
|
||||||
|
class FreqStack:
|
||||||
|
def __init__(self):
|
||||||
|
# Maps each value to its current frequency
|
||||||
|
self.freq = defaultdict(int)
|
||||||
|
# Maps each frequency to a stack of values at that frequency
|
||||||
|
self.group = defaultdict(list)
|
||||||
|
# Track the current maximum frequency
|
||||||
|
self.max_freq = 0
|
||||||
|
|
||||||
|
def push(self, val: int) -> None:
|
||||||
|
# Increment frequency for this value
|
||||||
|
self.freq[val] += 1
|
||||||
|
f = self.freq[val]
|
||||||
|
|
||||||
|
# Add to the stack at this frequency level
|
||||||
|
self.group[f].append(val)
|
||||||
|
|
||||||
|
# Update max frequency if this is a new high
|
||||||
|
if f > self.max_freq:
|
||||||
|
self.max_freq = f
|
||||||
|
|
||||||
|
def pop(self) -> int:
|
||||||
|
# Pop from the highest frequency stack (most recent at that level)
|
||||||
|
val = self.group[self.max_freq].pop()
|
||||||
|
|
||||||
|
# Decrement this value's frequency
|
||||||
|
self.freq[val] -= 1
|
||||||
|
|
||||||
|
# If no more elements at max frequency, decrease max_freq
|
||||||
|
if not self.group[self.max_freq]:
|
||||||
|
self.max_freq -= 1
|
||||||
|
|
||||||
|
return val
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(1) for both push and pop — all operations are hash map lookups and stack push/pop.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n) — we store each pushed element in the group stacks, and each unique value in the freq map.
|
||||||
|
|
||||||
|
This solution uses the "frequency as a level" insight. Each value at frequency f appears in `group[f]`. Popping from the highest level gives us the most frequent element, and since each level is a stack, we automatically get the most recently pushed element at that frequency.
|
||||||
|
|
||||||
|
- approach_name: Heap-Based
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
import heapq
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
|
||||||
|
class FreqStack:
|
||||||
|
def __init__(self):
|
||||||
|
# Maps each value to its current frequency
|
||||||
|
self.freq = defaultdict(int)
|
||||||
|
# Max-heap storing (-freq, -timestamp, val)
|
||||||
|
self.heap = []
|
||||||
|
# Timestamp counter for tie-breaking
|
||||||
|
self.timestamp = 0
|
||||||
|
|
||||||
|
def push(self, val: int) -> None:
|
||||||
|
# Increment frequency and timestamp
|
||||||
|
self.freq[val] += 1
|
||||||
|
self.timestamp += 1
|
||||||
|
|
||||||
|
# Push to heap with negative values for max-heap behavior
|
||||||
|
# (-frequency, -timestamp, val)
|
||||||
|
heapq.heappush(self.heap, (-self.freq[val], -self.timestamp, val))
|
||||||
|
|
||||||
|
def pop(self) -> int:
|
||||||
|
# Pop the element with max frequency (and most recent if tied)
|
||||||
|
_, _, val = heapq.heappop(self.heap)
|
||||||
|
|
||||||
|
# Decrement frequency
|
||||||
|
self.freq[val] -= 1
|
||||||
|
|
||||||
|
return val
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(log n) for both push and pop due to heap operations.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n) — the heap stores every pushed element.
|
||||||
|
|
||||||
|
This approach uses a max-heap (simulated with negative values in Python's min-heap). Each push adds a tuple of (-frequency, -timestamp, value). The heap automatically orders by maximum frequency, then by most recent timestamp.
|
||||||
|
|
||||||
|
While simpler to conceptualize, this is less efficient than the stack-of-stacks approach. It's included to show an alternative design and illustrate why the optimal solution is preferred.
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
title: Maximum Fruits Harvested After at Most K Steps
|
||||||
|
slug: maximum-fruits-harvested-after-at-most-k-steps
|
||||||
|
difficulty: hard
|
||||||
|
leetcode_id: 2106
|
||||||
|
leetcode_url: https://leetcode.com/problems/maximum-fruits-harvested-after-at-most-k-steps/
|
||||||
|
categories:
|
||||||
|
- arrays
|
||||||
|
- binary-search
|
||||||
|
patterns:
|
||||||
|
- sliding-window
|
||||||
|
- prefix-sum
|
||||||
|
- two-pointers
|
||||||
|
|
||||||
|
description: |
|
||||||
|
Fruits are available at some positions on an infinite x-axis. You are given a 2D integer array `fruits` where `fruits[i] = [position_i, amount_i]` depicts `amount_i` fruits at the position `position_i`. `fruits` is already **sorted** by `position_i` in **ascending order**, and each `position_i` is **unique**.
|
||||||
|
|
||||||
|
You are also given an integer `startPos` and an integer `k`. Initially, you are at the position `startPos`. From any position, you can either walk to the **left or right**. It takes **one step** to move **one unit** on the x-axis, and you can walk **at most** `k` steps in total. For every position you reach, you harvest all the fruits at that position, and the fruits will disappear from that position.
|
||||||
|
|
||||||
|
Return *the **maximum total number** of fruits you can harvest*.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `1 <= fruits.length <= 10^5`
|
||||||
|
- `fruits[i].length == 2`
|
||||||
|
- `0 <= startPos, position_i <= 2 * 10^5`
|
||||||
|
- `position_i-1 < position_i` for any `i > 0` (0-indexed)
|
||||||
|
- `1 <= amount_i <= 10^4`
|
||||||
|
- `0 <= k <= 2 * 10^5`
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: "fruits = [[2,8],[6,3],[8,6]], startPos = 5, k = 4"
|
||||||
|
output: "9"
|
||||||
|
explanation: "Move right to position 6 (harvest 3 fruits), then right to position 8 (harvest 6 fruits). Total: 3 + 6 = 9 fruits in 3 steps."
|
||||||
|
- input: "fruits = [[0,9],[4,1],[5,7],[6,2],[7,4],[10,9]], startPos = 5, k = 4"
|
||||||
|
output: "14"
|
||||||
|
explanation: "Harvest 7 at position 5, move left to 4 (harvest 1), move right to 6 (harvest 2), move right to 7 (harvest 4). Total: 7 + 1 + 2 + 4 = 14 fruits in 4 steps."
|
||||||
|
- input: "fruits = [[0,3],[6,4],[8,5]], startPos = 3, k = 2"
|
||||||
|
output: "0"
|
||||||
|
explanation: "With only 2 steps from position 3, you cannot reach any position with fruits."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Picture yourself standing at `startPos` on a number line with fruit baskets scattered at various positions. You have exactly `k` steps to spend, and you want to collect as many fruits as possible.
|
||||||
|
|
||||||
|
The key insight is that **optimal paths follow a specific pattern**: you either go left first then right, or right first then left, or only in one direction. You never benefit from changing direction more than once because backtracking wastes steps.
|
||||||
|
|
||||||
|
Think of it like this: if you walk `x` steps left and then turn around to walk `y` steps right, you've used `x + x + y = 2x + y` steps (you traverse the left portion twice). Similarly, going right first then left uses `2y + x` steps.
|
||||||
|
|
||||||
|
This means for any contiguous segment `[left, right]` of fruit positions that we want to harvest:
|
||||||
|
- If `left < startPos < right`, we must turn around once
|
||||||
|
- The cost is `min(2 * (startPos - left) + (right - startPos), 2 * (right - startPos) + (startPos - left))`
|
||||||
|
- We pick the direction that minimises backtracking
|
||||||
|
|
||||||
|
Since we want to maximise fruits within a step budget, we can use a **sliding window** over the sorted fruit positions, expanding and shrinking to find the maximum sum where the path cost stays within `k`.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using a **Sliding Window with Two Pointers**:
|
||||||
|
|
||||||
|
**Step 1: Build a prefix sum array**
|
||||||
|
|
||||||
|
- Create a prefix sum of fruit amounts to quickly compute the sum of any contiguous range
|
||||||
|
- `prefix[i]` = total fruits from index `0` to `i-1`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Define the step cost function**
|
||||||
|
|
||||||
|
- For a window `[left, right]` of fruit positions, calculate the minimum steps needed:
|
||||||
|
- If both positions are on the same side of `startPos`, cost is simply the distance to the farther one
|
||||||
|
- If `startPos` is between them, cost is `min(2 * left_dist + right_dist, left_dist + 2 * right_dist)`
|
||||||
|
- This accounts for going one direction, then backtracking
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Slide the window**
|
||||||
|
|
||||||
|
- Use two pointers `left` and `right` to define the current window of fruit positions
|
||||||
|
- Expand `right` to include more fruits
|
||||||
|
- When the step cost exceeds `k`, shrink from `left`
|
||||||
|
- Track the maximum fruit sum across all valid windows
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 4: Return the maximum**
|
||||||
|
|
||||||
|
- The answer is the maximum sum found across all valid windows
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Treating This as a Simple Range Problem
|
||||||
|
description: |
|
||||||
|
A common mistake is to think you can simply harvest all fruits within distance `k` from `startPos`. This ignores the backtracking cost.
|
||||||
|
|
||||||
|
For example, if `startPos = 5`, `k = 4`, and fruits are at positions `3` and `8`:
|
||||||
|
- Distance to `3` is `2`, distance to `8` is `3`
|
||||||
|
- But visiting both requires going left 2 steps, then right 5 steps = 7 total steps, exceeding `k = 4`
|
||||||
|
|
||||||
|
You must account for the **round-trip cost** when collecting fruits on both sides.
|
||||||
|
wrong_approach: "Collect all fruits within distance k"
|
||||||
|
correct_approach: "Calculate actual path cost including backtracking"
|
||||||
|
|
||||||
|
- title: Checking All Possible Paths Naively
|
||||||
|
description: |
|
||||||
|
Trying every possible `(left_distance, right_distance)` combination and summing fruits leads to **O(n * k)** or worse complexity.
|
||||||
|
|
||||||
|
With `n = 10^5` and `k = 2 * 10^5`, this approach will TLE.
|
||||||
|
|
||||||
|
The sliding window approach reduces this to O(n) by maintaining a valid window and never re-scanning already-processed positions.
|
||||||
|
wrong_approach: "Enumerate all possible left/right distances"
|
||||||
|
correct_approach: "Sliding window with two pointers"
|
||||||
|
|
||||||
|
- title: Forgetting to Handle Single-Direction Paths
|
||||||
|
description: |
|
||||||
|
When all reachable fruits are on one side of `startPos`, the cost is simply the distance to the farthest fruit (no backtracking).
|
||||||
|
|
||||||
|
Make sure your cost function handles:
|
||||||
|
- All fruits to the left: cost = `startPos - leftmost_position`
|
||||||
|
- All fruits to the right: cost = `rightmost_position - startPos`
|
||||||
|
- Fruits on both sides: cost = minimum of two turn-around strategies
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Sliding window on sorted data**: When the array is sorted and you need to find an optimal contiguous segment, sliding window is often the key"
|
||||||
|
- "**Turn-around costs matter**: In path problems, backtracking doubles the distance for that segment"
|
||||||
|
- "**Prefix sums for range queries**: Precompute cumulative sums to get O(1) range sum queries"
|
||||||
|
- "**Two-pointer optimisation**: The monotonic nature of the cost function allows us to shrink/expand the window efficiently"
|
||||||
|
|
||||||
|
time_complexity: "O(n). Each fruit position is visited at most twice (once when expanding, once when shrinking the window)."
|
||||||
|
space_complexity: "O(n). We store a prefix sum array of size `n + 1`."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Sliding Window with Two Pointers
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def max_total_fruits(fruits: list[list[int]], start_pos: int, k: int) -> int:
|
||||||
|
n = len(fruits)
|
||||||
|
|
||||||
|
# Build prefix sum for quick range sum queries
|
||||||
|
prefix = [0] * (n + 1)
|
||||||
|
for i in range(n):
|
||||||
|
prefix[i + 1] = prefix[i] + fruits[i][1]
|
||||||
|
|
||||||
|
def get_sum(left: int, right: int) -> int:
|
||||||
|
"""Get sum of fruits from index left to right (inclusive)."""
|
||||||
|
return prefix[right + 1] - prefix[left]
|
||||||
|
|
||||||
|
def min_steps(left_pos: int, right_pos: int) -> int:
|
||||||
|
"""Calculate minimum steps to visit positions from left_pos to right_pos."""
|
||||||
|
# If start is to the right of all positions, just go left
|
||||||
|
if start_pos >= right_pos:
|
||||||
|
return start_pos - left_pos
|
||||||
|
# If start is to the left of all positions, just go right
|
||||||
|
if start_pos <= left_pos:
|
||||||
|
return right_pos - start_pos
|
||||||
|
# Start is in between - must turn around once
|
||||||
|
left_dist = start_pos - left_pos
|
||||||
|
right_dist = right_pos - start_pos
|
||||||
|
# Go left first then right, or right first then left
|
||||||
|
return min(2 * left_dist + right_dist, left_dist + 2 * right_dist)
|
||||||
|
|
||||||
|
max_fruits = 0
|
||||||
|
left = 0
|
||||||
|
|
||||||
|
for right in range(n):
|
||||||
|
# Shrink window while steps exceed budget
|
||||||
|
while left <= right and min_steps(fruits[left][0], fruits[right][0]) > k:
|
||||||
|
left += 1
|
||||||
|
|
||||||
|
# Update maximum if window is valid
|
||||||
|
if left <= right:
|
||||||
|
max_fruits = max(max_fruits, get_sum(left, right))
|
||||||
|
|
||||||
|
return max_fruits
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — Each index is processed at most twice by the two pointers.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n) — Prefix sum array of size `n + 1`.
|
||||||
|
|
||||||
|
We slide a window over fruit positions, expanding the right boundary and shrinking the left when the path cost exceeds `k`. The prefix sum allows O(1) range sum queries. The key insight is that as `right` increases, the minimum valid `left` only increases (monotonic), enabling the two-pointer technique.
|
||||||
|
|
||||||
|
- approach_name: Binary Search for Each Right Boundary
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def max_total_fruits(fruits: list[list[int]], start_pos: int, k: int) -> int:
|
||||||
|
import bisect
|
||||||
|
|
||||||
|
n = len(fruits)
|
||||||
|
positions = [f[0] for f in fruits]
|
||||||
|
|
||||||
|
# Build prefix sum
|
||||||
|
prefix = [0] * (n + 1)
|
||||||
|
for i in range(n):
|
||||||
|
prefix[i + 1] = prefix[i] + fruits[i][1]
|
||||||
|
|
||||||
|
def get_sum(left: int, right: int) -> int:
|
||||||
|
if left > right or left < 0 or right >= n:
|
||||||
|
return 0
|
||||||
|
return prefix[right + 1] - prefix[left]
|
||||||
|
|
||||||
|
max_fruits = 0
|
||||||
|
|
||||||
|
# Try each position as the rightmost point
|
||||||
|
for right in range(n):
|
||||||
|
right_pos = fruits[right][0]
|
||||||
|
|
||||||
|
# Case 1: Go right first, then left
|
||||||
|
# Steps = 2 * (right_pos - start_pos) + (start_pos - left_pos)
|
||||||
|
# left_pos >= start_pos - (k - 2 * (right_pos - start_pos))
|
||||||
|
if right_pos >= start_pos:
|
||||||
|
remaining = k - (right_pos - start_pos)
|
||||||
|
if remaining >= 0:
|
||||||
|
# Can go left by remaining // 2 after going right
|
||||||
|
left_reach = start_pos - remaining // 2
|
||||||
|
left_idx = bisect.bisect_left(positions, left_reach)
|
||||||
|
max_fruits = max(max_fruits, get_sum(left_idx, right))
|
||||||
|
|
||||||
|
# Case 2: Go left first, then right
|
||||||
|
# We handle this by trying each position as leftmost
|
||||||
|
# (Symmetric logic)
|
||||||
|
|
||||||
|
# Try each position as the leftmost point
|
||||||
|
for left in range(n):
|
||||||
|
left_pos = fruits[left][0]
|
||||||
|
|
||||||
|
if left_pos <= start_pos:
|
||||||
|
remaining = k - (start_pos - left_pos)
|
||||||
|
if remaining >= 0:
|
||||||
|
right_reach = start_pos + remaining // 2
|
||||||
|
right_idx = bisect.bisect_right(positions, right_reach) - 1
|
||||||
|
max_fruits = max(max_fruits, get_sum(left, right_idx))
|
||||||
|
|
||||||
|
return max_fruits
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n log n) — Binary search for each of the n positions.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n) — Prefix sum and positions arrays.
|
||||||
|
|
||||||
|
For each position, we use binary search to find the farthest reachable position in the opposite direction given the remaining step budget. This is correct but slightly slower than the two-pointer approach. The two-pointer method exploits the monotonic relationship between boundaries.
|
||||||
184
backend/data/questions/maximum-length-of-pair-chain.yaml
Normal file
184
backend/data/questions/maximum-length-of-pair-chain.yaml
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
title: Maximum Length of Pair Chain
|
||||||
|
slug: maximum-length-of-pair-chain
|
||||||
|
difficulty: medium
|
||||||
|
leetcode_id: 646
|
||||||
|
leetcode_url: https://leetcode.com/problems/maximum-length-of-pair-chain/
|
||||||
|
categories:
|
||||||
|
- arrays
|
||||||
|
- sorting
|
||||||
|
- dynamic-programming
|
||||||
|
patterns:
|
||||||
|
- greedy
|
||||||
|
- intervals
|
||||||
|
|
||||||
|
description: |
|
||||||
|
You are given an array of `n` pairs `pairs` where `pairs[i] = [left_i, right_i]` and `left_i < right_i`.
|
||||||
|
|
||||||
|
A pair `p2 = [c, d]` **follows** a pair `p1 = [a, b]` if `b < c`. A **chain** of pairs can be formed in this fashion.
|
||||||
|
|
||||||
|
Return *the length of the longest chain which can be formed*.
|
||||||
|
|
||||||
|
You do not need to use up all the given intervals. You can select pairs in any order.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `n == pairs.length`
|
||||||
|
- `1 <= n <= 1000`
|
||||||
|
- `-1000 <= left_i < right_i <= 1000`
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: "pairs = [[1,2],[2,3],[3,4]]"
|
||||||
|
output: "2"
|
||||||
|
explanation: "The longest chain is [1,2] -> [3,4]. Note that [1,2] -> [2,3] is invalid because 2 is not strictly less than 2."
|
||||||
|
- input: "pairs = [[1,2],[7,8],[4,5]]"
|
||||||
|
output: "3"
|
||||||
|
explanation: "The longest chain is [1,2] -> [4,5] -> [7,8]."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Imagine you're scheduling non-overlapping meetings in a conference room, but with a twist — between each meeting, you need at least one time unit of buffer. Your goal is to fit as many meetings as possible.
|
||||||
|
|
||||||
|
The key insight is that **greedily picking pairs that end earliest** leaves the most room for subsequent pairs. If you always choose the pair with the smallest ending value (right bound) that can legally follow the current chain, you maximise your opportunities to extend the chain further.
|
||||||
|
|
||||||
|
Think of it like this: given two pairs that could both extend your current chain, the one ending earlier gives you more flexibility for what comes next. This is the classic **activity selection** problem from algorithm theory.
|
||||||
|
|
||||||
|
Why does greedy work here? Because the "follow" relationship only depends on the ending value of the previous pair. By sorting on end values and greedily extending, we make locally optimal choices that lead to the globally optimal solution.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using the **Greedy Approach** (sorting by end values):
|
||||||
|
|
||||||
|
**Step 1: Sort pairs by their ending value**
|
||||||
|
|
||||||
|
- Sort the `pairs` array by each pair's right (ending) element
|
||||||
|
- This ensures we always consider pairs that "finish earliest" first
|
||||||
|
- Pairs that end earlier leave more room for subsequent pairs
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Initialise tracking variables**
|
||||||
|
|
||||||
|
- `chain_length`: Set to `1` since we'll always include at least the first pair
|
||||||
|
- `current_end`: Set to the end value of the first pair after sorting
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Iterate through the remaining pairs**
|
||||||
|
|
||||||
|
- For each pair starting from index 1, check if its start value is greater than `current_end`
|
||||||
|
- If `pair[0] > current_end`, this pair can extend the chain:
|
||||||
|
- Increment `chain_length` by 1
|
||||||
|
- Update `current_end` to this pair's end value
|
||||||
|
- If not, skip this pair — it overlaps with our current chain end
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 4: Return the result**
|
||||||
|
|
||||||
|
- Return `chain_length` as the maximum chain length
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
This greedy approach works because by always choosing the pair that ends earliest, we maximise the remaining space for future pairs, leading to the longest possible chain.
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Sorting by Start Value Instead of End Value
|
||||||
|
description: |
|
||||||
|
A common mistake is sorting by the start value (left bound) instead of the end value.
|
||||||
|
|
||||||
|
Consider `pairs = [[1,5],[2,3],[4,6]]`:
|
||||||
|
- Sorted by start: `[[1,5],[2,3],[4,6]]` → Chain `[1,5] -> [4,6]` = length 2
|
||||||
|
- Sorted by end: `[[2,3],[1,5],[4,6]]` → Chain `[2,3] -> [4,6]` = length 2
|
||||||
|
|
||||||
|
But with `[[1,10],[2,3],[4,5]]`:
|
||||||
|
- Sorted by start: `[[1,10],[2,3],[4,5]]` → Only `[1,10]` = length 1
|
||||||
|
- Sorted by end: `[[2,3],[4,5],[1,10]]` → Chain `[2,3] -> [4,5]` = length 2
|
||||||
|
|
||||||
|
The pair `[1,10]` "blocks" everything when chosen first, but sorting by end value reveals that `[2,3]` and `[4,5]` fit together.
|
||||||
|
wrong_approach: "Sort by left_i (start value)"
|
||||||
|
correct_approach: "Sort by right_i (end value)"
|
||||||
|
|
||||||
|
- title: Using >= Instead of > for the Follow Condition
|
||||||
|
description: |
|
||||||
|
The problem states `p2` follows `p1` if `b < c` (strictly less than). Using `<=` would incorrectly allow pairs like `[1,2]` to be followed by `[2,3]`.
|
||||||
|
|
||||||
|
With `pairs = [[1,2],[2,3]]`:
|
||||||
|
- Correct (`b < c`): `[1,2]` cannot be followed by `[2,3]` because `2 < 2` is false
|
||||||
|
- Wrong (`b <= c`): Would incorrectly chain them
|
||||||
|
|
||||||
|
Always use strict inequality: `pair[0] > current_end`.
|
||||||
|
wrong_approach: "Check if pair[0] >= current_end"
|
||||||
|
correct_approach: "Check if pair[0] > current_end (strict inequality)"
|
||||||
|
|
||||||
|
- title: Using Dynamic Programming When Greedy Suffices
|
||||||
|
description: |
|
||||||
|
This problem can be solved with DP (O(n^2) time), but the greedy approach is more efficient (O(n log n) due to sorting).
|
||||||
|
|
||||||
|
While DP works by computing `dp[i]` = longest chain ending at pair `i`, the greedy approach recognises that we only need to track the current chain's end value. The insight that "ending earliest is always best" eliminates the need to consider all possible previous pairs.
|
||||||
|
|
||||||
|
With `n <= 1000`, both approaches pass, but greedy is cleaner and faster.
|
||||||
|
wrong_approach: "O(n^2) DP computing all subproblems"
|
||||||
|
correct_approach: "O(n log n) greedy with sorting"
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Activity selection pattern**: When selecting non-overlapping intervals, sort by end time and greedily pick the earliest-ending valid option"
|
||||||
|
- "**Greedy vs DP**: Recognise when greedy provides the optimal solution — here, the locally optimal choice (earliest end) leads to the global optimum"
|
||||||
|
- "**Sorting as a preprocessing step**: Many interval problems become tractable once sorted by start or end values"
|
||||||
|
- "**Related problems**: This pattern applies to Meeting Rooms II, Non-overlapping Intervals, and Minimum Number of Arrows to Burst Balloons"
|
||||||
|
|
||||||
|
time_complexity: "O(n log n). Sorting dominates the time complexity. The subsequent single pass through the sorted array is O(n)."
|
||||||
|
space_complexity: "O(1) or O(n). Depends on the sorting implementation — in-place sorting uses O(1) extra space, while Python's Timsort uses O(n) auxiliary space."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Greedy (Sort by End Value)
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def find_longest_chain(pairs: list[list[int]]) -> int:
|
||||||
|
# Sort pairs by their ending value (right bound)
|
||||||
|
# This ensures we always consider pairs that "finish earliest" first
|
||||||
|
pairs.sort(key=lambda x: x[1])
|
||||||
|
|
||||||
|
# Start with the first pair in our chain
|
||||||
|
chain_length = 1
|
||||||
|
current_end = pairs[0][1]
|
||||||
|
|
||||||
|
# Try to extend the chain with each subsequent pair
|
||||||
|
for i in range(1, len(pairs)):
|
||||||
|
# Can this pair follow our current chain?
|
||||||
|
# The start must be strictly greater than our chain's end
|
||||||
|
if pairs[i][0] > current_end:
|
||||||
|
chain_length += 1
|
||||||
|
current_end = pairs[i][1] # Update chain end
|
||||||
|
|
||||||
|
return chain_length
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n log n) — Sorting dominates; the linear scan is O(n).
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) extra space (ignoring the space used by sorting).
|
||||||
|
|
||||||
|
By sorting pairs by their end values, we ensure that greedily picking the first valid pair always leads to the optimal solution. This is the classic activity selection algorithm.
|
||||||
|
|
||||||
|
- approach_name: Dynamic Programming
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def find_longest_chain(pairs: list[list[int]]) -> int:
|
||||||
|
# Sort pairs by start value for DP approach
|
||||||
|
pairs.sort(key=lambda x: x[0])
|
||||||
|
n = len(pairs)
|
||||||
|
|
||||||
|
# dp[i] = longest chain ending with pairs[i]
|
||||||
|
dp = [1] * n
|
||||||
|
|
||||||
|
# For each pair, check all previous pairs
|
||||||
|
for i in range(1, n):
|
||||||
|
for j in range(i):
|
||||||
|
# Can pairs[j] be followed by pairs[i]?
|
||||||
|
if pairs[j][1] < pairs[i][0]:
|
||||||
|
dp[i] = max(dp[i], dp[j] + 1)
|
||||||
|
|
||||||
|
# Return the maximum chain length
|
||||||
|
return max(dp)
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n^2) — Nested loops comparing all pairs.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n) — DP array storing chain lengths.
|
||||||
|
|
||||||
|
This approach computes the longest chain ending at each pair by checking all previous pairs. While correct, it's less efficient than the greedy approach. Included to show the DP perspective and for cases where greedy intuition isn't immediately clear.
|
||||||
213
backend/data/questions/maximum-product-subarray.yaml
Normal file
213
backend/data/questions/maximum-product-subarray.yaml
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
title: Maximum Product Subarray
|
||||||
|
slug: maximum-product-subarray
|
||||||
|
difficulty: medium
|
||||||
|
leetcode_id: 152
|
||||||
|
leetcode_url: https://leetcode.com/problems/maximum-product-subarray/
|
||||||
|
categories:
|
||||||
|
- arrays
|
||||||
|
- dynamic-programming
|
||||||
|
patterns:
|
||||||
|
- dynamic-programming
|
||||||
|
|
||||||
|
description: |
|
||||||
|
Given an integer array `nums`, find a subarray that has the largest product, and return *the product*.
|
||||||
|
|
||||||
|
The test cases are generated so that the answer will fit in a **32-bit** integer.
|
||||||
|
|
||||||
|
**Note** that the product of an array with a single element is the value of that element.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `1 <= nums.length <= 2 * 10^4`
|
||||||
|
- `-10 <= nums[i] <= 10`
|
||||||
|
- The product of any subarray of `nums` is **guaranteed** to fit in a **32-bit** integer.
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: "nums = [2,3,-2,4]"
|
||||||
|
output: "6"
|
||||||
|
explanation: "The subarray [2,3] has the largest product 6."
|
||||||
|
- input: "nums = [-2,0,-1]"
|
||||||
|
output: "0"
|
||||||
|
explanation: "The result cannot be 2, because [-2,-1] is not a subarray (elements must be contiguous)."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
This problem is a twist on the classic **Maximum Subarray Sum** (Kadane's algorithm). However, products behave differently from sums in one crucial way: **multiplying by a negative number flips the sign**.
|
||||||
|
|
||||||
|
Imagine you're tracking the largest product ending at each position. If you encounter a negative number, your current large positive product suddenly becomes a large *negative* product. But here's the twist: if you encounter *another* negative number later, that large negative product could flip back to become the largest positive product!
|
||||||
|
|
||||||
|
Think of it like this: negative numbers are "wild cards" that can transform your worst result into your best result. A very negative product multiplied by another negative number becomes a very positive product.
|
||||||
|
|
||||||
|
The key insight is that we need to track **both** the maximum and minimum products ending at each position:
|
||||||
|
- The **maximum** product could come from extending the previous maximum (if current is positive) or from the previous minimum flipping sign
|
||||||
|
- The **minimum** product matters because it might become the maximum after hitting a negative number
|
||||||
|
|
||||||
|
This dual-tracking approach lets us handle the sign-flipping nature of multiplication in a single pass.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using a **Dynamic Programming** approach that tracks both maximum and minimum products:
|
||||||
|
|
||||||
|
**Step 1: Initialise variables**
|
||||||
|
|
||||||
|
- `max_product`: Set to `nums[0]` — the global maximum product found
|
||||||
|
- `current_max`: Set to `nums[0]` — the maximum product ending at the current position
|
||||||
|
- `current_min`: Set to `nums[0]` — the minimum product ending at the current position (needed for negative flips)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Iterate through the array starting from index 1**
|
||||||
|
|
||||||
|
For each element `num`:
|
||||||
|
|
||||||
|
- If `num` is negative, swap `current_max` and `current_min` — this prepares for the sign flip
|
||||||
|
- Update `current_max`: the larger of `num` alone (start fresh) or `current_max * num` (extend)
|
||||||
|
- Update `current_min`: the smaller of `num` alone (start fresh) or `current_min * num` (extend)
|
||||||
|
- Update `max_product` if `current_max` is greater
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Return the result**
|
||||||
|
|
||||||
|
- Return `max_product` — the largest product subarray found
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
The swap trick before updating handles the sign flip elegantly: when we multiply by a negative, what was maximum becomes minimum and vice versa.
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Ignoring Negative Numbers
|
||||||
|
description: |
|
||||||
|
A common mistake is to apply Kadane's algorithm directly without modification:
|
||||||
|
```python
|
||||||
|
current_max = max(num, current_max * num)
|
||||||
|
```
|
||||||
|
|
||||||
|
This fails because it discards negative products that could become positive later.
|
||||||
|
|
||||||
|
For example, with `nums = [2, 3, -2, 4, -1]`:
|
||||||
|
- Without tracking minimum: After `-2`, you'd start fresh with `4`, missing that `2 * 3 * -2 * 4 * -1 = 48`
|
||||||
|
- The two negatives cancel out, giving a larger product than any positive-only subarray
|
||||||
|
wrong_approach: "Only track maximum product (standard Kadane's)"
|
||||||
|
correct_approach: "Track both maximum AND minimum products"
|
||||||
|
|
||||||
|
- title: Forgetting Zeros Reset Everything
|
||||||
|
description: |
|
||||||
|
When you encounter `0`, any product including it becomes `0`. The subarray must either:
|
||||||
|
- End before the zero
|
||||||
|
- Start after the zero
|
||||||
|
- Be just `[0]` if everything else is negative
|
||||||
|
|
||||||
|
The algorithm handles this naturally: `max(num, current_max * num)` will choose `0` (starting fresh) when `current_max * 0 = 0`.
|
||||||
|
wrong_approach: "Special-case zeros with complex logic"
|
||||||
|
correct_approach: "Let the max/min comparison handle zeros naturally"
|
||||||
|
|
||||||
|
- title: Wrong Swap Timing
|
||||||
|
description: |
|
||||||
|
Some implementations swap `current_max` and `current_min` *after* the multiplication, which is incorrect.
|
||||||
|
|
||||||
|
The swap must happen **before** computing the new values because we're preparing for how the negative will affect the *previous* max and min.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Wrong: swap after
|
||||||
|
current_max = max(num, current_max * num)
|
||||||
|
current_min = min(num, current_min * num)
|
||||||
|
if num < 0:
|
||||||
|
current_max, current_min = current_min, current_max # Too late!
|
||||||
|
```
|
||||||
|
wrong_approach: "Swap after computing new max/min"
|
||||||
|
correct_approach: "Swap before computing when num is negative"
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Track extremes in both directions**: When operations can flip signs, track both maximum and minimum values"
|
||||||
|
- "**Negative numbers need special handling**: In products, a very negative value can become the maximum after another negative"
|
||||||
|
- "**Extension of Kadane's algorithm**: The `max(current, current * num)` pattern extends to products with the min-tracking addition"
|
||||||
|
- "**Foundation for similar problems**: This dual-tracking technique applies to other problems where values can flip between positive and negative"
|
||||||
|
|
||||||
|
time_complexity: "O(n). We traverse the array exactly once, performing constant-time operations at each element."
|
||||||
|
space_complexity: "O(1). We only use three variables (`max_product`, `current_max`, `current_min`) regardless of input size."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Dynamic Programming with Min/Max Tracking
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def max_product(nums: list[int]) -> int:
|
||||||
|
if not nums:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Initialise with first element
|
||||||
|
max_product = nums[0]
|
||||||
|
current_max = nums[0]
|
||||||
|
current_min = nums[0]
|
||||||
|
|
||||||
|
for i in range(1, len(nums)):
|
||||||
|
num = nums[i]
|
||||||
|
|
||||||
|
# If negative, max becomes min and min becomes max after multiplication
|
||||||
|
if num < 0:
|
||||||
|
current_max, current_min = current_min, current_max
|
||||||
|
|
||||||
|
# Either start fresh with num, or extend the previous subarray
|
||||||
|
current_max = max(num, current_max * num)
|
||||||
|
current_min = min(num, current_min * num)
|
||||||
|
|
||||||
|
# Update global maximum
|
||||||
|
max_product = max(max_product, current_max)
|
||||||
|
|
||||||
|
return max_product
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — Single pass through the array.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — Only three variables used.
|
||||||
|
|
||||||
|
The key insight is swapping max and min when we encounter a negative number. This handles the sign-flip elegantly: multiplying a large negative (previous min) by a negative gives a large positive (new max).
|
||||||
|
|
||||||
|
- approach_name: Dynamic Programming (Explicit Candidates)
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def max_product(nums: list[int]) -> int:
|
||||||
|
if not nums:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
max_product = nums[0]
|
||||||
|
current_max = nums[0]
|
||||||
|
current_min = nums[0]
|
||||||
|
|
||||||
|
for i in range(1, len(nums)):
|
||||||
|
num = nums[i]
|
||||||
|
|
||||||
|
# Consider all three candidates for new max and min
|
||||||
|
candidates = (num, current_max * num, current_min * num)
|
||||||
|
|
||||||
|
current_max = max(candidates)
|
||||||
|
current_min = min(candidates)
|
||||||
|
|
||||||
|
max_product = max(max_product, current_max)
|
||||||
|
|
||||||
|
return max_product
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — Single pass through the array.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — Only three variables used.
|
||||||
|
|
||||||
|
This version makes the logic more explicit by considering all three candidates at once: the number itself (starting fresh), extending with previous max, or extending with previous min. The swap is implicit in the candidate selection.
|
||||||
|
|
||||||
|
- approach_name: Brute Force
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def max_product(nums: list[int]) -> int:
|
||||||
|
n = len(nums)
|
||||||
|
max_product = nums[0]
|
||||||
|
|
||||||
|
# Try every possible subarray
|
||||||
|
for i in range(n):
|
||||||
|
product = 1
|
||||||
|
for j in range(i, n):
|
||||||
|
product *= nums[j]
|
||||||
|
max_product = max(max_product, product)
|
||||||
|
|
||||||
|
return max_product
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n^2) — Nested loops checking all subarrays.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — Only tracking the current product and maximum.
|
||||||
|
|
||||||
|
This approach checks every contiguous subarray by fixing a start index and extending to all possible end indices. While correct, it's too slow for large inputs. Included to show why the DP approach is necessary.
|
||||||
179
backend/data/questions/maximum-subarray.yaml
Normal file
179
backend/data/questions/maximum-subarray.yaml
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
title: Maximum Subarray
|
||||||
|
slug: maximum-subarray
|
||||||
|
difficulty: medium
|
||||||
|
leetcode_id: 53
|
||||||
|
leetcode_url: https://leetcode.com/problems/maximum-subarray/
|
||||||
|
categories:
|
||||||
|
- arrays
|
||||||
|
- dynamic-programming
|
||||||
|
patterns:
|
||||||
|
- dynamic-programming
|
||||||
|
|
||||||
|
function_signature: "def max_subarray(nums: list[int]) -> int:"
|
||||||
|
|
||||||
|
test_cases:
|
||||||
|
visible:
|
||||||
|
- input: { nums: [-2, 1, -3, 4, -1, 2, 1, -5, 4] }
|
||||||
|
expected: 6
|
||||||
|
- input: { nums: [1] }
|
||||||
|
expected: 1
|
||||||
|
- input: { nums: [5, 4, -1, 7, 8] }
|
||||||
|
expected: 23
|
||||||
|
hidden:
|
||||||
|
- input: { nums: [-1] }
|
||||||
|
expected: -1
|
||||||
|
- input: { nums: [-2, -1] }
|
||||||
|
expected: -1
|
||||||
|
- input: { nums: [1, 2, 3, 4] }
|
||||||
|
expected: 10
|
||||||
|
- input: { nums: [-1, -2, -3, -4] }
|
||||||
|
expected: -1
|
||||||
|
|
||||||
|
description: |
|
||||||
|
Given an integer array `nums`, find the subarray with the largest sum, and return *its sum*.
|
||||||
|
|
||||||
|
A **subarray** is a contiguous non-empty sequence of elements within an array.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `1 <= nums.length <= 10^5`
|
||||||
|
- `-10^4 <= nums[i] <= 10^4`
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: "nums = [-2,1,-3,4,-1,2,1,-5,4]"
|
||||||
|
output: "6"
|
||||||
|
explanation: "The subarray [4,-1,2,1] has the largest sum 6."
|
||||||
|
- input: "nums = [1]"
|
||||||
|
output: "1"
|
||||||
|
explanation: "The subarray [1] has the largest sum 1."
|
||||||
|
- input: "nums = [5,4,-1,7,8]"
|
||||||
|
output: "23"
|
||||||
|
explanation: "The entire array [5,4,-1,7,8] has the largest sum 23."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Imagine you're walking along a number line, collecting values. At each step, you face a simple decision: should you keep accumulating, or cut your losses and start fresh?
|
||||||
|
|
||||||
|
Think of it like this: you're tracking a "running sum" as you traverse the array. If your running sum becomes negative, it's **dragging you down** — any future subarray would be better off starting fresh without that negative baggage.
|
||||||
|
|
||||||
|
The key insight of **Kadane's Algorithm** is that at each position `i`, you have exactly two choices:
|
||||||
|
1. **Extend** the previous subarray by adding `nums[i]` (if the previous sum helps)
|
||||||
|
2. **Start fresh** at `nums[i]` (if the previous sum was negative)
|
||||||
|
|
||||||
|
Mathematically: `current_sum = max(nums[i], current_sum + nums[i])`
|
||||||
|
|
||||||
|
We also track the maximum sum seen so far, because the optimal subarray might end before the last element.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using **Kadane's Algorithm**:
|
||||||
|
|
||||||
|
**Step 1: Initialise tracking variables**
|
||||||
|
|
||||||
|
- `current_sum = nums[0]`: The sum of the current subarray ending at position i
|
||||||
|
- `max_sum = nums[0]`: The maximum sum we've found so far
|
||||||
|
- Starting with `nums[0]` (not 0) handles all-negative arrays correctly
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Iterate from index 1 to the end**
|
||||||
|
|
||||||
|
- For each element `nums[i]`:
|
||||||
|
- **Decide**: extend or restart? `current_sum = max(nums[i], current_sum + nums[i])`
|
||||||
|
- If `current_sum + nums[i] >= nums[i]`, extend (previous sum is non-negative)
|
||||||
|
- If `current_sum + nums[i] < nums[i]`, restart (previous sum was negative)
|
||||||
|
- **Update maximum**: `max_sum = max(max_sum, current_sum)`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Return the result**
|
||||||
|
|
||||||
|
- Return `max_sum` — the largest subarray sum encountered
|
||||||
|
- Note: we don't return `current_sum` because the optimal subarray might have ended earlier
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
This elegant algorithm makes the locally optimal choice at each step (extend or restart), which leads to the globally optimal solution.
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Initialising max_sum to 0
|
||||||
|
description: |
|
||||||
|
If all elements are negative (e.g., `[-3, -2, -1]`), the maximum subarray sum is `-1`, not `0`.
|
||||||
|
|
||||||
|
Initialising `max_sum = 0` would incorrectly return `0` for such arrays, as if an empty subarray were valid (it's not — the problem requires a non-empty subarray).
|
||||||
|
|
||||||
|
Always initialise with `nums[0]` to guarantee a valid answer.
|
||||||
|
wrong_approach: "max_sum = 0"
|
||||||
|
correct_approach: "max_sum = nums[0]"
|
||||||
|
|
||||||
|
- title: Only Returning current_sum at the End
|
||||||
|
description: |
|
||||||
|
The maximum subarray doesn't necessarily include the last element! Consider `[4, -1, 2, 1, -5]`: the maximum sum is `6` (subarray `[4, -1, 2, 1]`), but `current_sum` at the end is `1`.
|
||||||
|
|
||||||
|
You must track `max_sum` throughout the traversal and update it whenever `current_sum` exceeds it.
|
||||||
|
wrong_approach: "return current_sum"
|
||||||
|
correct_approach: "Track max_sum and return it"
|
||||||
|
|
||||||
|
- title: Confusing Subarray with Subsequence
|
||||||
|
description: |
|
||||||
|
A **subarray** must be contiguous — you cannot skip elements. This is why Kadane's algorithm works: at each position, the subarray either extends from the previous position or starts fresh.
|
||||||
|
|
||||||
|
A **subsequence** allows skipping elements, which would require a different approach entirely.
|
||||||
|
wrong_approach: "Thinking you can skip elements"
|
||||||
|
correct_approach: "Subarray = contiguous sequence"
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Kadane's Algorithm**: The classic O(n) solution for maximum subarray — memorise it!"
|
||||||
|
- "**Local vs global optimum**: Sometimes making locally optimal choices (extend or restart) yields the global optimum"
|
||||||
|
- "**Initialisation matters**: Using `nums[0]` instead of `0` handles edge cases correctly"
|
||||||
|
- "**Pattern recognition**: This 'extend or restart' logic applies to many contiguous subarray problems"
|
||||||
|
|
||||||
|
time_complexity: "O(n). Single pass through the array, with O(1) work at each position."
|
||||||
|
space_complexity: "O(1). Only two variables (`current_sum` and `max_sum`) regardless of input size."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Kadane's Algorithm
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def max_subarray(nums: list[int]) -> int:
|
||||||
|
# Initialise with first element (handles all-negative arrays)
|
||||||
|
current_sum = nums[0]
|
||||||
|
max_sum = nums[0]
|
||||||
|
|
||||||
|
# Process remaining elements
|
||||||
|
for i in range(1, len(nums)):
|
||||||
|
# Decision: extend previous subarray or start fresh?
|
||||||
|
# Extend if previous sum helps, otherwise restart
|
||||||
|
current_sum = max(nums[i], current_sum + nums[i])
|
||||||
|
|
||||||
|
# Update maximum if current subarray is better
|
||||||
|
max_sum = max(max_sum, current_sum)
|
||||||
|
|
||||||
|
return max_sum
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — Single pass through the array.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — Only two variables needed.
|
||||||
|
|
||||||
|
At each position, we decide whether to extend the previous subarray or start a new one. We track the maximum sum seen throughout the traversal. This greedy choice at each step produces the globally optimal result.
|
||||||
|
|
||||||
|
- approach_name: Brute Force
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def max_subarray(nums: list[int]) -> int:
|
||||||
|
n = len(nums)
|
||||||
|
max_sum = nums[0]
|
||||||
|
|
||||||
|
# Try every possible starting point
|
||||||
|
for i in range(n):
|
||||||
|
current_sum = 0
|
||||||
|
# Try every possible ending point from i
|
||||||
|
for j in range(i, n):
|
||||||
|
current_sum += nums[j]
|
||||||
|
max_sum = max(max_sum, current_sum)
|
||||||
|
|
||||||
|
return max_sum
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n²) — Nested loops checking all subarrays.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — Only tracking sums.
|
||||||
|
|
||||||
|
This approach tries every possible subarray by fixing a start index and extending to each possible end index. While correct, it's too slow for large inputs (up to 10 billion operations for n = 10^5).
|
||||||
193
backend/data/questions/maximum-sum-circular-subarray.yaml
Normal file
193
backend/data/questions/maximum-sum-circular-subarray.yaml
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
title: Maximum Sum Circular Subarray
|
||||||
|
slug: maximum-sum-circular-subarray
|
||||||
|
difficulty: medium
|
||||||
|
leetcode_id: 918
|
||||||
|
leetcode_url: https://leetcode.com/problems/maximum-sum-circular-subarray/
|
||||||
|
categories:
|
||||||
|
- arrays
|
||||||
|
- dynamic-programming
|
||||||
|
patterns:
|
||||||
|
- dynamic-programming
|
||||||
|
|
||||||
|
description: |
|
||||||
|
Given a **circular integer array** `nums` of length `n`, return *the maximum possible sum of a non-empty **subarray** of* `nums`.
|
||||||
|
|
||||||
|
A **circular array** means the end of the array connects to the beginning of the array. Formally, the next element of `nums[i]` is `nums[(i + 1) % n]` and the previous element of `nums[i]` is `nums[(i - 1 + n) % n]`.
|
||||||
|
|
||||||
|
A **subarray** may only include each element of the fixed buffer `nums` at most once. Formally, for a subarray `nums[i], nums[i + 1], ..., nums[j]`, there does not exist `i <= k1`, `k2 <= j` with `k1 % n == k2 % n`.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `n == nums.length`
|
||||||
|
- `1 <= n <= 3 * 10^4`
|
||||||
|
- `-3 * 10^4 <= nums[i] <= 3 * 10^4`
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: "nums = [1,-2,3,-2]"
|
||||||
|
output: "3"
|
||||||
|
explanation: "Subarray [3] has maximum sum 3."
|
||||||
|
- input: "nums = [5,-3,5]"
|
||||||
|
output: "10"
|
||||||
|
explanation: "Subarray [5,5] has maximum sum 5 + 5 = 10. The subarray wraps around from the end to the beginning."
|
||||||
|
- input: "nums = [-3,-2,-3]"
|
||||||
|
output: "-2"
|
||||||
|
explanation: "Subarray [-2] has maximum sum -2. When all elements are negative, we must pick the least negative one."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Imagine the array as a circular track where you can start anywhere and collect consecutive elements. You want to find the stretch that gives you the maximum total.
|
||||||
|
|
||||||
|
The key insight is that there are only **two possible cases** for the maximum subarray in a circular array:
|
||||||
|
|
||||||
|
**Case 1: The maximum subarray does NOT wrap around** — It's a normal contiguous subarray somewhere in the middle. This is exactly what Kadane's algorithm solves.
|
||||||
|
|
||||||
|
**Case 2: The maximum subarray WRAPS around** — It uses elements from the end AND the beginning. Think of it like this: if the best subarray wraps around, it means we're *excluding* some elements in the middle. Those excluded elements form a contiguous subarray themselves!
|
||||||
|
|
||||||
|
Here's the beautiful insight: if we exclude a contiguous subarray from the total sum, we want to exclude the **minimum sum subarray** to maximise what's left. So:
|
||||||
|
- `Wrap-around max = Total sum - Minimum subarray sum`
|
||||||
|
|
||||||
|
By computing both the maximum subarray (Kadane's) and the minimum subarray (inverted Kadane's), we can handle both cases and return the larger result.
|
||||||
|
|
||||||
|
One edge case: if all elements are negative, the minimum subarray is the entire array, making the wrap-around sum equal to zero. But we must return a non-empty subarray, so we take the regular maximum (the least negative element).
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using **Kadane's Algorithm with a twist** — running it twice to find both maximum and minimum subarrays.
|
||||||
|
|
||||||
|
**Step 1: Initialise tracking variables**
|
||||||
|
|
||||||
|
- `total_sum`: Accumulate the sum of all elements
|
||||||
|
- `max_sum`: Track the maximum subarray sum (standard Kadane's)
|
||||||
|
- `current_max`: Current maximum ending at this position
|
||||||
|
- `min_sum`: Track the minimum subarray sum (inverted Kadane's)
|
||||||
|
- `current_min`: Current minimum ending at this position
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Iterate through the array once**
|
||||||
|
|
||||||
|
- Add each element to `total_sum`
|
||||||
|
- For maximum subarray: `current_max = max(num, current_max + num)`, then update `max_sum`
|
||||||
|
- For minimum subarray: `current_min = min(num, current_min + num)`, then update `min_sum`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Handle the two cases**
|
||||||
|
|
||||||
|
- **Case 1 (no wrap):** The answer is `max_sum` from Kadane's
|
||||||
|
- **Case 2 (wrap around):** The answer is `total_sum - min_sum`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 4: Handle the all-negative edge case**
|
||||||
|
|
||||||
|
- If `max_sum < 0`, all elements are negative
|
||||||
|
- In this case, `total_sum - min_sum = 0` (excluding everything), which is invalid
|
||||||
|
- Return `max_sum` directly (the least negative element)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 5: Return the result**
|
||||||
|
|
||||||
|
- Return `max(max_sum, total_sum - min_sum)`
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Forgetting the All-Negative Case
|
||||||
|
description: |
|
||||||
|
When all elements are negative, the minimum subarray is the entire array, so `total_sum - min_sum = 0`. But the problem requires a **non-empty** subarray, so returning `0` is wrong.
|
||||||
|
|
||||||
|
For example, with `nums = [-3, -2, -3]`:
|
||||||
|
- `total_sum = -8`
|
||||||
|
- `min_sum = -8` (the whole array)
|
||||||
|
- `total_sum - min_sum = 0` — incorrect!
|
||||||
|
|
||||||
|
The correct answer is `-2` (the least negative element). Always check if `max_sum < 0` and return it directly in that case.
|
||||||
|
wrong_approach: "Return max(max_sum, total_sum - min_sum) without checking for all-negative"
|
||||||
|
correct_approach: "If max_sum < 0, return max_sum directly"
|
||||||
|
|
||||||
|
- title: Using Brute Force with Circular Wrapping
|
||||||
|
description: |
|
||||||
|
Trying to enumerate all circular subarrays by doubling the array or using modular arithmetic leads to O(n^2) complexity:
|
||||||
|
- For each starting index `i`, try all ending indices
|
||||||
|
- This is way too slow for `n = 3 * 10^4`
|
||||||
|
|
||||||
|
The key insight is recognising that a wrap-around subarray is equivalent to excluding a middle subarray. This transforms the problem into finding both max and min subarrays in a single O(n) pass.
|
||||||
|
wrong_approach: "Enumerate all circular subarrays"
|
||||||
|
correct_approach: "Use the total_sum - min_sum trick for wrap-around case"
|
||||||
|
|
||||||
|
- title: Running Kadane's Twice Separately
|
||||||
|
description: |
|
||||||
|
You might think you need two separate passes through the array — one for maximum and one for minimum. But both can be computed in a **single pass** since they're independent calculations at each position.
|
||||||
|
|
||||||
|
Combining them saves a constant factor and keeps the code cleaner.
|
||||||
|
wrong_approach: "Two separate loops through the array"
|
||||||
|
correct_approach: "Single loop computing both max and min simultaneously"
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Circular to linear transformation**: A wrap-around subarray equals `total_sum - (middle excluded subarray)`. Finding the max wrap-around is equivalent to finding the min middle subarray."
|
||||||
|
- "**Inverted Kadane's**: To find the minimum subarray, apply Kadane's logic with `min` instead of `max`."
|
||||||
|
- "**Edge case awareness**: Always consider what happens when all elements share the same sign (all negative or all positive)."
|
||||||
|
- "**Foundation for harder problems**: This technique of transforming circular problems into linear ones applies to many variants."
|
||||||
|
|
||||||
|
time_complexity: "O(n). We traverse the array exactly once, performing constant-time operations at each element."
|
||||||
|
space_complexity: "O(1). We only use a fixed number of variables (`total_sum`, `max_sum`, `current_max`, `min_sum`, `current_min`) regardless of input size."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Modified Kadane's Algorithm
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def max_subarray_sum_circular(nums: list[int]) -> int:
|
||||||
|
# Track both max and min subarrays simultaneously
|
||||||
|
total_sum = 0
|
||||||
|
max_sum = nums[0]
|
||||||
|
current_max = 0
|
||||||
|
min_sum = nums[0]
|
||||||
|
current_min = 0
|
||||||
|
|
||||||
|
for num in nums:
|
||||||
|
# Accumulate total for wrap-around calculation
|
||||||
|
total_sum += num
|
||||||
|
|
||||||
|
# Standard Kadane's for maximum subarray
|
||||||
|
current_max = max(num, current_max + num)
|
||||||
|
max_sum = max(max_sum, current_max)
|
||||||
|
|
||||||
|
# Inverted Kadane's for minimum subarray
|
||||||
|
current_min = min(num, current_min + num)
|
||||||
|
min_sum = min(min_sum, current_min)
|
||||||
|
|
||||||
|
# Edge case: all elements negative
|
||||||
|
# total_sum - min_sum would be 0, but we need non-empty subarray
|
||||||
|
if max_sum < 0:
|
||||||
|
return max_sum
|
||||||
|
|
||||||
|
# Return the better of: no-wrap (max_sum) or wrap (total - min)
|
||||||
|
return max(max_sum, total_sum - min_sum)
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — Single pass through the array.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — Only five tracking variables used.
|
||||||
|
|
||||||
|
We compute both the maximum and minimum subarray sums in one pass. The maximum handles Case 1 (no wrap), and `total_sum - min_sum` handles Case 2 (wrap around). We take the larger, with special handling when all elements are negative.
|
||||||
|
|
||||||
|
- approach_name: Brute Force
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def max_subarray_sum_circular(nums: list[int]) -> int:
|
||||||
|
n = len(nums)
|
||||||
|
max_sum = nums[0]
|
||||||
|
|
||||||
|
# Try every starting position
|
||||||
|
for i in range(n):
|
||||||
|
current_sum = 0
|
||||||
|
# Try every length (1 to n)
|
||||||
|
for length in range(1, n + 1):
|
||||||
|
# Use modulo for circular indexing
|
||||||
|
current_sum += nums[(i + length - 1) % n]
|
||||||
|
max_sum = max(max_sum, current_sum)
|
||||||
|
|
||||||
|
return max_sum
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n^2) — Nested loops trying all starting positions and lengths.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — Only tracking current and max sums.
|
||||||
|
|
||||||
|
This approach explicitly tries every possible circular subarray by iterating through all starting positions and lengths. While correct, it's too slow for the given constraints and will result in TLE. Included to illustrate why the Kadane's approach is necessary.
|
||||||
190
backend/data/questions/merge-intervals.yaml
Normal file
190
backend/data/questions/merge-intervals.yaml
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
title: Merge Intervals
|
||||||
|
slug: merge-intervals
|
||||||
|
difficulty: medium
|
||||||
|
leetcode_id: 56
|
||||||
|
leetcode_url: https://leetcode.com/problems/merge-intervals/
|
||||||
|
categories:
|
||||||
|
- arrays
|
||||||
|
- sorting
|
||||||
|
patterns:
|
||||||
|
- intervals
|
||||||
|
|
||||||
|
description: |
|
||||||
|
Given an array of `intervals` where `intervals[i] = [start_i, end_i]`, merge all overlapping intervals, and return *an array of the non-overlapping intervals that cover all the intervals in the input*.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `1 <= intervals.length <= 10^4`
|
||||||
|
- `intervals[i].length == 2`
|
||||||
|
- `0 <= start_i <= end_i <= 10^4`
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: "intervals = [[1,3],[2,6],[8,10],[15,18]]"
|
||||||
|
output: "[[1,6],[8,10],[15,18]]"
|
||||||
|
explanation: "Since intervals [1,3] and [2,6] overlap, merge them into [1,6]."
|
||||||
|
- input: "intervals = [[1,4],[4,5]]"
|
||||||
|
output: "[[1,5]]"
|
||||||
|
explanation: "Intervals [1,4] and [4,5] are considered overlapping because they share endpoint 4."
|
||||||
|
- input: "intervals = [[4,7],[1,4]]"
|
||||||
|
output: "[[1,7]]"
|
||||||
|
explanation: "After sorting by start time, [1,4] comes before [4,7]. Since they share endpoint 4, they merge into [1,7]."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Imagine you have several meetings scheduled throughout the day, and you want to see which time blocks are actually occupied. Some meetings overlap — if one runs from 1pm to 3pm and another from 2pm to 4pm, you're really busy from 1pm to 4pm continuously.
|
||||||
|
|
||||||
|
The key insight is that **overlapping intervals are easier to detect when they're sorted**. If intervals are sorted by their start times, then two consecutive intervals overlap if and only if the second interval's start is less than or equal to the first interval's end.
|
||||||
|
|
||||||
|
Think of it like this: line up all intervals on a number line, sorted by where they begin. As you scan left to right, each new interval either:
|
||||||
|
1. **Overlaps** with the previous one (its start ≤ previous end) — extend the current merged interval
|
||||||
|
2. **Doesn't overlap** (its start > previous end) — start a new merged interval
|
||||||
|
|
||||||
|
This mental model transforms a potentially complex problem into a simple linear scan after sorting.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using a **Sort and Merge** approach:
|
||||||
|
|
||||||
|
**Step 1: Handle edge cases**
|
||||||
|
|
||||||
|
- If the input has 0 or 1 intervals, return it as-is (nothing to merge)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Sort intervals by start time**
|
||||||
|
|
||||||
|
- Sort the intervals array by the first element (start time) of each interval
|
||||||
|
- This ensures that any overlapping intervals will be adjacent after sorting
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Initialise the result**
|
||||||
|
|
||||||
|
- Create a `merged` list and add the first interval to it
|
||||||
|
- This first interval becomes our "current" interval to potentially extend
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 4: Iterate through remaining intervals**
|
||||||
|
|
||||||
|
- For each interval starting from index 1:
|
||||||
|
- Get the last interval in our `merged` list (the one we might extend)
|
||||||
|
- If the current interval's start ≤ the last merged interval's end → they overlap
|
||||||
|
- Update the last merged interval's end to be the maximum of both ends
|
||||||
|
- Otherwise → no overlap
|
||||||
|
- Append the current interval as a new entry in `merged`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 5: Return the result**
|
||||||
|
|
||||||
|
- Return the `merged` list containing all non-overlapping intervals
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Forgetting to Sort First
|
||||||
|
description: |
|
||||||
|
Without sorting, you cannot reliably detect overlaps with a single pass. Consider `[[2,6],[1,3],[8,10]]`:
|
||||||
|
- Unsorted: [2,6] and [1,3] are not adjacent, making overlap detection complex
|
||||||
|
- Sorted: [1,3] and [2,6] become adjacent, and their overlap is obvious (3 ≥ 2)
|
||||||
|
|
||||||
|
Attempting to merge without sorting requires comparing every pair of intervals, resulting in O(n²) complexity.
|
||||||
|
wrong_approach: "Iterate through unsorted intervals"
|
||||||
|
correct_approach: "Sort by start time first, then merge in one pass"
|
||||||
|
|
||||||
|
- title: Incorrect Overlap Condition
|
||||||
|
description: |
|
||||||
|
Two intervals `[a, b]` and `[c, d]` (where `a ≤ c` after sorting) overlap when `c ≤ b`, not `c < b`.
|
||||||
|
|
||||||
|
For example, `[1,4]` and `[4,5]` share the point 4 and should merge to `[1,5]`. Using strict inequality (`c < b`) would incorrectly keep them separate.
|
||||||
|
wrong_approach: "Check if current.start < previous.end"
|
||||||
|
correct_approach: "Check if current.start <= previous.end"
|
||||||
|
|
||||||
|
- title: Not Taking the Maximum End
|
||||||
|
description: |
|
||||||
|
When merging overlapping intervals, the new end should be `max(previous.end, current.end)`, not just `current.end`.
|
||||||
|
|
||||||
|
Consider `[1,10]` and `[2,5]`: the second interval is entirely contained within the first. The merged result should be `[1,10]`, not `[1,5]`.
|
||||||
|
wrong_approach: "Set merged.end = current.end"
|
||||||
|
correct_approach: "Set merged.end = max(merged.end, current.end)"
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Sort first for interval problems**: Sorting by start time makes overlap detection trivial — just compare adjacent intervals"
|
||||||
|
- "**The intervals pattern**: This problem introduces the foundational technique used in many interval problems (insert interval, meeting rooms, etc.)"
|
||||||
|
- "**In-place vs. new list**: We build a new result list rather than modifying in place, which is cleaner and avoids index shifting issues"
|
||||||
|
- "**Edge case awareness**: Remember that touching intervals (sharing an endpoint) count as overlapping"
|
||||||
|
|
||||||
|
time_complexity: "O(n log n). Sorting dominates at O(n log n), and the subsequent merge pass is O(n)."
|
||||||
|
space_complexity: "O(n). The `merged` result list can contain up to n intervals if none overlap. Sorting may use O(log n) stack space depending on implementation."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Sort and Merge
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def merge(intervals: list[list[int]]) -> list[list[int]]:
|
||||||
|
# Edge case: nothing to merge
|
||||||
|
if len(intervals) <= 1:
|
||||||
|
return intervals
|
||||||
|
|
||||||
|
# Sort intervals by start time
|
||||||
|
intervals.sort(key=lambda x: x[0])
|
||||||
|
|
||||||
|
# Start with the first interval
|
||||||
|
merged = [intervals[0]]
|
||||||
|
|
||||||
|
for current in intervals[1:]:
|
||||||
|
# Get the last interval in our merged list
|
||||||
|
last = merged[-1]
|
||||||
|
|
||||||
|
# Check if current overlaps with last (start <= end means overlap)
|
||||||
|
if current[0] <= last[1]:
|
||||||
|
# Extend the last interval's end if needed
|
||||||
|
last[1] = max(last[1], current[1])
|
||||||
|
else:
|
||||||
|
# No overlap - add current as a new interval
|
||||||
|
merged.append(current)
|
||||||
|
|
||||||
|
return merged
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n log n) — Dominated by the sorting step.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n) — For the result list; O(log n) for sorting.
|
||||||
|
|
||||||
|
After sorting by start time, we make a single pass through the intervals. Each interval either extends the previous merged interval or starts a new one. The sorting ensures we never miss an overlap.
|
||||||
|
|
||||||
|
- approach_name: Brute Force (Compare All Pairs)
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def merge(intervals: list[list[int]]) -> list[list[int]]:
|
||||||
|
if not intervals:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Convert to a set of tuples for easier manipulation
|
||||||
|
interval_set = set(tuple(i) for i in intervals)
|
||||||
|
|
||||||
|
# Keep merging until no more merges possible
|
||||||
|
changed = True
|
||||||
|
while changed:
|
||||||
|
changed = False
|
||||||
|
intervals_list = list(interval_set)
|
||||||
|
|
||||||
|
for i in range(len(intervals_list)):
|
||||||
|
for j in range(i + 1, len(intervals_list)):
|
||||||
|
a, b = intervals_list[i], intervals_list[j]
|
||||||
|
|
||||||
|
# Check if intervals overlap
|
||||||
|
if a[0] <= b[1] and b[0] <= a[1]:
|
||||||
|
# Merge them
|
||||||
|
merged = (min(a[0], b[0]), max(a[1], b[1]))
|
||||||
|
interval_set.discard(a)
|
||||||
|
interval_set.discard(b)
|
||||||
|
interval_set.add(merged)
|
||||||
|
changed = True
|
||||||
|
break
|
||||||
|
if changed:
|
||||||
|
break
|
||||||
|
|
||||||
|
return [list(i) for i in interval_set]
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n³) — In the worst case, we do n merge operations, each requiring O(n²) comparisons.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n) — For storing the set of intervals.
|
||||||
|
|
||||||
|
This approach repeatedly scans all pairs looking for overlaps. While correct, it's highly inefficient. Each merge might create new overlap opportunities, requiring multiple passes. This illustrates why sorting first is essential — it allows us to find all overlaps in a single O(n) pass.
|
||||||
199
backend/data/questions/merge-sorted-array.yaml
Normal file
199
backend/data/questions/merge-sorted-array.yaml
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
title: Merge Sorted Array
|
||||||
|
slug: merge-sorted-array
|
||||||
|
difficulty: easy
|
||||||
|
leetcode_id: 88
|
||||||
|
leetcode_url: https://leetcode.com/problems/merge-sorted-array/
|
||||||
|
categories:
|
||||||
|
- arrays
|
||||||
|
- two-pointers
|
||||||
|
- sorting
|
||||||
|
patterns:
|
||||||
|
- two-pointers
|
||||||
|
|
||||||
|
description: |
|
||||||
|
You are given two integer arrays `nums1` and `nums2`, sorted in **non-decreasing order**, and two integers `m` and `n`, representing the number of elements in `nums1` and `nums2` respectively.
|
||||||
|
|
||||||
|
**Merge** `nums1` and `nums2` into a single array sorted in **non-decreasing order**.
|
||||||
|
|
||||||
|
The final sorted array should not be returned by the function, but instead be *stored inside the array* `nums1`. To accommodate this, `nums1` has a length of `m + n`, where the first `m` elements denote the elements that should be merged, and the last `n` elements are set to `0` and should be ignored. `nums2` has a length of `n`.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `nums1.length == m + n`
|
||||||
|
- `nums2.length == n`
|
||||||
|
- `0 <= m, n <= 200`
|
||||||
|
- `1 <= m + n <= 200`
|
||||||
|
- `-10^9 <= nums1[i], nums2[j] <= 10^9`
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: "nums1 = [1,2,3,0,0,0], m = 3, nums2 = [2,5,6], n = 3"
|
||||||
|
output: "[1,2,2,3,5,6]"
|
||||||
|
explanation: "The arrays we are merging are [1,2,3] and [2,5,6]. The result of the merge is [1,2,2,3,5,6]."
|
||||||
|
- input: "nums1 = [1], m = 1, nums2 = [], n = 0"
|
||||||
|
output: "[1]"
|
||||||
|
explanation: "The arrays we are merging are [1] and []. The result of the merge is [1]."
|
||||||
|
- input: "nums1 = [0], m = 0, nums2 = [1], n = 1"
|
||||||
|
output: "[1]"
|
||||||
|
explanation: "The arrays we are merging are [] and [1]. The result of the merge is [1]. Because m = 0, there are no elements in nums1. The 0 is only there to ensure the merge result can fit in nums1."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Imagine you have two sorted stacks of numbered cards that you need to combine into one sorted stack, but you must place the result in the first stack's holder which already has empty slots at the end.
|
||||||
|
|
||||||
|
The key insight is to **fill from the back**. Since `nums1` has extra space at the end (the `n` zeros), we can place elements starting from position `m + n - 1` and work backwards. By comparing the *largest* remaining elements from both arrays and placing the larger one at the current end position, we avoid overwriting any elements we still need.
|
||||||
|
|
||||||
|
Think of it like this: if you start from the front, you'd overwrite elements in `nums1` before you've had a chance to place them. But starting from the back is safe because those positions are just placeholder zeros.
|
||||||
|
|
||||||
|
This backwards approach lets us merge in-place without needing any extra space.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using the **Three Pointers (Merge from End)** approach:
|
||||||
|
|
||||||
|
**Step 1: Initialise three pointers**
|
||||||
|
|
||||||
|
- `p1`: Points to the last valid element in `nums1` (index `m - 1`)
|
||||||
|
- `p2`: Points to the last element in `nums2` (index `n - 1`)
|
||||||
|
- `p`: Points to the last position in `nums1` (index `m + n - 1`) — where we'll place the next largest element
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Compare and place elements from back to front**
|
||||||
|
|
||||||
|
- While both `p1` and `p2` are valid (>= 0):
|
||||||
|
- Compare `nums1[p1]` and `nums2[p2]`
|
||||||
|
- Place the **larger** value at `nums1[p]`
|
||||||
|
- Decrement the pointer of whichever array we took from
|
||||||
|
- Decrement `p`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Handle remaining elements in nums2**
|
||||||
|
|
||||||
|
- If `p2 >= 0`, copy remaining elements from `nums2` to `nums1`
|
||||||
|
- We don't need to handle remaining `nums1` elements — they're already in place!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
This approach works because we're always placing the largest unplaced element at the rightmost unfilled position.
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Starting from the Front
|
||||||
|
description: |
|
||||||
|
A natural instinct is to merge from the beginning, like in the classic merge step of merge sort.
|
||||||
|
|
||||||
|
However, if you start placing elements at `nums1[0]`, you'll overwrite elements in `nums1` that you haven't processed yet. For example, with `nums1 = [1,2,3,0,0,0]` and `nums2 = [2,5,6]`, placing `1` at index 0 is fine, but then where do you put `nums1`'s original `1`? It gets overwritten.
|
||||||
|
|
||||||
|
Starting from the back avoids this because the back positions are just placeholder zeros.
|
||||||
|
wrong_approach: "Merge from the front (index 0)"
|
||||||
|
correct_approach: "Merge from the back (index m + n - 1)"
|
||||||
|
|
||||||
|
- title: Forgetting to Copy Remaining nums2 Elements
|
||||||
|
description: |
|
||||||
|
After the main loop, if `p2 >= 0`, there are still elements in `nums2` that need to be copied. For example, if `nums1 = [4,5,6,0,0,0]` and `nums2 = [1,2,3]`, all of `nums2` needs to go at the front.
|
||||||
|
|
||||||
|
You don't need to worry about leftover `nums1` elements — they're already in their correct positions since we're modifying `nums1` in-place.
|
||||||
|
wrong_approach: "Only running the comparison loop"
|
||||||
|
correct_approach: "Copy remaining nums2 elements after the main loop"
|
||||||
|
|
||||||
|
- title: Using Extra Space
|
||||||
|
description: |
|
||||||
|
Some solutions create a new array to hold the merged result, then copy it back to `nums1`. While this works, it uses O(m + n) extra space.
|
||||||
|
|
||||||
|
The problem specifically mentions `nums1` has extra space at the end — this hint suggests an in-place solution is expected. The three-pointer approach achieves O(1) extra space.
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Merge from the end**: When merging into an array with trailing space, work backwards to avoid overwriting elements you still need"
|
||||||
|
- "**Three-pointer technique**: Use separate pointers for each input and the output position for clean, in-place merging"
|
||||||
|
- "**Foundation for merge sort**: This is the merge step used in merge sort — understanding it helps with divide-and-conquer algorithms"
|
||||||
|
- "**In-place modification**: When a problem says 'modify in-place', look for ways to avoid extra space by clever pointer manipulation"
|
||||||
|
|
||||||
|
time_complexity: "O(m + n). Each element from both arrays is visited exactly once."
|
||||||
|
space_complexity: "O(1). We only use three pointer variables regardless of input size."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Three Pointers (Merge from End)
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def merge(nums1: list[int], m: int, nums2: list[int], n: int) -> None:
|
||||||
|
# Start from the end of both arrays
|
||||||
|
p1 = m - 1 # Last valid element in nums1
|
||||||
|
p2 = n - 1 # Last element in nums2
|
||||||
|
p = m + n - 1 # Position to place next element
|
||||||
|
|
||||||
|
# Compare and place larger element at the end
|
||||||
|
while p1 >= 0 and p2 >= 0:
|
||||||
|
if nums1[p1] > nums2[p2]:
|
||||||
|
nums1[p] = nums1[p1]
|
||||||
|
p1 -= 1
|
||||||
|
else:
|
||||||
|
nums1[p] = nums2[p2]
|
||||||
|
p2 -= 1
|
||||||
|
p -= 1
|
||||||
|
|
||||||
|
# Copy remaining elements from nums2 (if any)
|
||||||
|
# No need to copy remaining nums1 elements - they're already in place
|
||||||
|
while p2 >= 0:
|
||||||
|
nums1[p] = nums2[p2]
|
||||||
|
p2 -= 1
|
||||||
|
p -= 1
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(m + n) — We process each element from both arrays exactly once.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — Only three pointer variables used, regardless of input size.
|
||||||
|
|
||||||
|
By filling from the back, we guarantee that we never overwrite an element we still need. The larger of the two current elements gets placed at the rightmost unfilled position, and we work our way left until all elements are merged.
|
||||||
|
|
||||||
|
- approach_name: Copy and Sort
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def merge(nums1: list[int], m: int, nums2: list[int], n: int) -> None:
|
||||||
|
# Copy nums2 into the empty slots of nums1
|
||||||
|
for i in range(n):
|
||||||
|
nums1[m + i] = nums2[i]
|
||||||
|
|
||||||
|
# Sort the entire array
|
||||||
|
nums1.sort()
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O((m + n) log(m + n)) — Dominated by the sorting step.
|
||||||
|
|
||||||
|
**Space Complexity:** O(log(m + n)) — Space used by the sorting algorithm.
|
||||||
|
|
||||||
|
This approach ignores the fact that both arrays are already sorted and just combines then sorts. While simple to implement, it's less efficient than the optimal O(m + n) solution. It's included here to show why leveraging the sorted property matters.
|
||||||
|
|
||||||
|
- approach_name: Extra Array
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def merge(nums1: list[int], m: int, nums2: list[int], n: int) -> None:
|
||||||
|
# Create a copy of the valid elements in nums1
|
||||||
|
nums1_copy = nums1[:m]
|
||||||
|
|
||||||
|
# Two pointers for merging
|
||||||
|
p1 = 0 # Pointer for nums1_copy
|
||||||
|
p2 = 0 # Pointer for nums2
|
||||||
|
p = 0 # Pointer for placement in nums1
|
||||||
|
|
||||||
|
# Merge while both arrays have elements
|
||||||
|
while p1 < m and p2 < n:
|
||||||
|
if nums1_copy[p1] <= nums2[p2]:
|
||||||
|
nums1[p] = nums1_copy[p1]
|
||||||
|
p1 += 1
|
||||||
|
else:
|
||||||
|
nums1[p] = nums2[p2]
|
||||||
|
p2 += 1
|
||||||
|
p += 1
|
||||||
|
|
||||||
|
# Copy remaining elements
|
||||||
|
while p1 < m:
|
||||||
|
nums1[p] = nums1_copy[p1]
|
||||||
|
p1 += 1
|
||||||
|
p += 1
|
||||||
|
|
||||||
|
while p2 < n:
|
||||||
|
nums1[p] = nums2[p2]
|
||||||
|
p2 += 1
|
||||||
|
p += 1
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(m + n) — We process each element once.
|
||||||
|
|
||||||
|
**Space Complexity:** O(m) — We create a copy of the first m elements.
|
||||||
|
|
||||||
|
This is the classic merge approach from merge sort, but it requires extra space to avoid overwriting elements. By copying `nums1`'s valid elements first, we can safely merge from the front. While this achieves O(m + n) time, the extra space makes it suboptimal compared to the three-pointer approach.
|
||||||
186
backend/data/questions/merge-strings-alternately.yaml
Normal file
186
backend/data/questions/merge-strings-alternately.yaml
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
title: Merge Strings Alternately
|
||||||
|
slug: merge-strings-alternately
|
||||||
|
difficulty: easy
|
||||||
|
leetcode_id: 1768
|
||||||
|
leetcode_url: https://leetcode.com/problems/merge-strings-alternately/
|
||||||
|
categories:
|
||||||
|
- strings
|
||||||
|
- two-pointers
|
||||||
|
patterns:
|
||||||
|
- two-pointers
|
||||||
|
|
||||||
|
description: |
|
||||||
|
You are given two strings `word1` and `word2`. Merge the strings by adding letters in alternating order, starting with `word1`. If a string is longer than the other, append the additional letters onto the end of the merged string.
|
||||||
|
|
||||||
|
Return *the merged string*.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `1 <= word1.length, word2.length <= 100`
|
||||||
|
- `word1` and `word2` consist of lowercase English letters.
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: 'word1 = "abc", word2 = "pqr"'
|
||||||
|
output: '"apbqcr"'
|
||||||
|
explanation: "The merged string is formed by alternating characters: a, p, b, q, c, r."
|
||||||
|
- input: 'word1 = "ab", word2 = "pqrs"'
|
||||||
|
output: '"apbqrs"'
|
||||||
|
explanation: 'Since word2 is longer, "rs" is appended to the end after alternating through "ab" and "pq".'
|
||||||
|
- input: 'word1 = "abcd", word2 = "pq"'
|
||||||
|
output: '"apbqcd"'
|
||||||
|
explanation: 'Since word1 is longer, "cd" is appended to the end after alternating through "ab" and "pq".'
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Imagine you have two decks of cards and you want to shuffle them together by taking one card from each deck alternately. You pick a card from the first deck, then one from the second, then back to the first, and so on.
|
||||||
|
|
||||||
|
When one deck runs out before the other, you simply place all the remaining cards from the longer deck on top.
|
||||||
|
|
||||||
|
This problem works exactly the same way with strings. Think of each character as a card. We **interleave** characters from both strings one at a time, and when one string is exhausted, we append whatever remains from the other.
|
||||||
|
|
||||||
|
The key insight is that we can process both strings simultaneously using either:
|
||||||
|
1. **Two pointers** - one for each string, advancing in lockstep
|
||||||
|
2. **Index-based iteration** - iterating up to the length of the longer string
|
||||||
|
|
||||||
|
Since we're building a new string character by character, this is a straightforward linear traversal problem.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using a **Two Pointers** approach:
|
||||||
|
|
||||||
|
**Step 1: Initialise variables**
|
||||||
|
|
||||||
|
- `result`: An empty list to collect characters (using a list is more efficient than string concatenation in Python)
|
||||||
|
- `i`: Pointer starting at `0` to track position in both strings
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Iterate while either string has characters**
|
||||||
|
|
||||||
|
- Use a `while` loop that continues as long as `i` is less than either string's length
|
||||||
|
- If `i < len(word1)`: append `word1[i]` to result
|
||||||
|
- If `i < len(word2)`: append `word2[i]` to result
|
||||||
|
- Increment `i` after each iteration
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Return the merged result**
|
||||||
|
|
||||||
|
- Join the list into a single string and return it
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
This approach naturally handles unequal string lengths. When one string is exhausted, only the remaining characters from the other string are appended.
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: String Concatenation in a Loop
|
||||||
|
description: |
|
||||||
|
In Python, using `result += char` inside a loop creates a new string object each time because strings are immutable. This leads to **O(n^2)** time complexity for building a string of length n.
|
||||||
|
|
||||||
|
Instead, collect characters in a list and use `''.join()` at the end for O(n) performance. While this optimisation may seem unnecessary for small strings (up to 200 characters here), it's a good habit for larger inputs.
|
||||||
|
wrong_approach: "result = result + word1[i] in a loop"
|
||||||
|
correct_approach: "Append to a list, then ''.join() at the end"
|
||||||
|
|
||||||
|
- title: Off-by-One Errors with Unequal Lengths
|
||||||
|
description: |
|
||||||
|
When iterating over two strings of different lengths, it's easy to accidentally index out of bounds.
|
||||||
|
|
||||||
|
For example, if `word1 = "ab"` and `word2 = "pqrs"`, iterating to `max(len(word1), len(word2))` means indices 2 and 3 are valid for `word2` but not for `word1`.
|
||||||
|
|
||||||
|
Always check `if i < len(word)` before accessing `word[i]`.
|
||||||
|
wrong_approach: "Accessing word1[i] without bounds check"
|
||||||
|
correct_approach: "Guard each access with if i < len(word)"
|
||||||
|
|
||||||
|
- title: Forgetting to Handle Remaining Characters
|
||||||
|
description: |
|
||||||
|
A common mistake is stopping the loop when the shorter string ends, forgetting to append the remaining characters from the longer string.
|
||||||
|
|
||||||
|
The condition `while i < len(word1) or i < len(word2)` ensures we continue until *both* strings are fully processed.
|
||||||
|
wrong_approach: "while i < len(word1) and i < len(word2)"
|
||||||
|
correct_approach: "while i < len(word1) or i < len(word2)"
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Two-pointer pattern**: When processing two sequences in parallel, use indices or pointers to track progress through each"
|
||||||
|
- "**Handle unequal lengths gracefully**: Use `or` in loop conditions and bounds checks to avoid index errors"
|
||||||
|
- "**String building efficiency**: In Python, prefer list accumulation with `join()` over repeated string concatenation"
|
||||||
|
- "**Foundation for harder problems**: This interleaving pattern appears in merge operations (merge sort), zipper problems, and alternating data structures"
|
||||||
|
|
||||||
|
time_complexity: "O(n + m). We iterate through each character of `word1` (length n) and `word2` (length m) exactly once."
|
||||||
|
space_complexity: "O(n + m). We create a new string of length `n + m` to store the result."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Two Pointers
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def merge_alternately(word1: str, word2: str) -> str:
|
||||||
|
# Use a list for efficient character accumulation
|
||||||
|
result = []
|
||||||
|
i = 0
|
||||||
|
|
||||||
|
# Continue while either string has characters left
|
||||||
|
while i < len(word1) or i < len(word2):
|
||||||
|
# Add character from word1 if available
|
||||||
|
if i < len(word1):
|
||||||
|
result.append(word1[i])
|
||||||
|
# Add character from word2 if available
|
||||||
|
if i < len(word2):
|
||||||
|
result.append(word2[i])
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# Join list into final string
|
||||||
|
return ''.join(result)
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n + m) — We visit each character exactly once.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n + m) — The result string contains all characters from both inputs.
|
||||||
|
|
||||||
|
We use a single index `i` to traverse both strings simultaneously. The `or` condition ensures we process all characters even when strings have different lengths. Bounds checking before each access prevents index errors.
|
||||||
|
|
||||||
|
- approach_name: Itertools Zip Longest
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
from itertools import zip_longest
|
||||||
|
|
||||||
|
def merge_alternately(word1: str, word2: str) -> str:
|
||||||
|
# zip_longest pairs characters, filling with '' when one string ends
|
||||||
|
result = []
|
||||||
|
for c1, c2 in zip_longest(word1, word2, fillvalue=''):
|
||||||
|
result.append(c1)
|
||||||
|
result.append(c2)
|
||||||
|
return ''.join(result)
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n + m) — Single pass through both strings.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n + m) — Result string stores all characters.
|
||||||
|
|
||||||
|
Python's `zip_longest` pairs elements from both strings, using `''` (empty string) as a fill value when one string is shorter. This elegantly handles unequal lengths without explicit bounds checking.
|
||||||
|
|
||||||
|
- approach_name: Two Separate Pointers
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def merge_alternately(word1: str, word2: str) -> str:
|
||||||
|
result = []
|
||||||
|
i, j = 0, 0
|
||||||
|
|
||||||
|
# Alternate while both strings have characters
|
||||||
|
while i < len(word1) and j < len(word2):
|
||||||
|
result.append(word1[i])
|
||||||
|
result.append(word2[j])
|
||||||
|
i += 1
|
||||||
|
j += 1
|
||||||
|
|
||||||
|
# Append remaining characters from word1
|
||||||
|
while i < len(word1):
|
||||||
|
result.append(word1[i])
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# Append remaining characters from word2
|
||||||
|
while j < len(word2):
|
||||||
|
result.append(word2[j])
|
||||||
|
j += 1
|
||||||
|
|
||||||
|
return ''.join(result)
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n + m) — Each character visited once across the three loops.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n + m) — Result string contains all characters.
|
||||||
|
|
||||||
|
This approach uses separate pointers `i` and `j` for each string. The first loop handles the interleaving, and the two remaining loops handle leftover characters. While slightly more verbose than the single-pointer approach, this pattern is useful when the two sequences need different handling during iteration.
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
title: Merge Triplets to Form Target Triplet
|
||||||
|
slug: merge-triplets-to-form-target-triplet
|
||||||
|
difficulty: medium
|
||||||
|
leetcode_id: 1899
|
||||||
|
leetcode_url: https://leetcode.com/problems/merge-triplets-to-form-target-triplet/
|
||||||
|
categories:
|
||||||
|
- arrays
|
||||||
|
patterns:
|
||||||
|
- greedy
|
||||||
|
|
||||||
|
description: |
|
||||||
|
A **triplet** is an array of three integers. You are given a 2D integer array `triplets`, where `triplets[i] = [a_i, b_i, c_i]` describes the i<sup>th</sup> **triplet**. You are also given an integer array `target = [x, y, z]` that describes the **triplet** you want to obtain.
|
||||||
|
|
||||||
|
To obtain `target`, you may apply the following operation on `triplets` **any number** of times (possibly **zero**):
|
||||||
|
|
||||||
|
- Choose two indices (0-indexed) `i` and `j` (`i != j`) and **update** `triplets[j]` to become `[max(a_i, a_j), max(b_i, b_j), max(c_i, c_j)]`.
|
||||||
|
- For example, if `triplets[i] = [2, 5, 3]` and `triplets[j] = [1, 7, 5]`, `triplets[j]` will be updated to `[max(2, 1), max(5, 7), max(3, 5)] = [2, 7, 5]`.
|
||||||
|
|
||||||
|
Return `true` *if it is possible to obtain the* `target` *triplet* `[x, y, z]` *as an element of* `triplets`, *or* `false` *otherwise*.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `1 <= triplets.length <= 10^5`
|
||||||
|
- `triplets[i].length == target.length == 3`
|
||||||
|
- `1 <= a_i, b_i, c_i, x, y, z <= 1000`
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: "triplets = [[2,5,3],[1,8,4],[1,7,5]], target = [2,7,5]"
|
||||||
|
output: "true"
|
||||||
|
explanation: "Choose the first and last triplets. Update the last to [max(2,1), max(5,7), max(3,5)] = [2,7,5]. The target triplet is now an element of triplets."
|
||||||
|
- input: "triplets = [[3,4,5],[4,5,6]], target = [3,2,5]"
|
||||||
|
output: "false"
|
||||||
|
explanation: "It is impossible to have [3,2,5] as an element because there is no 2 in any of the triplets."
|
||||||
|
- input: "triplets = [[2,5,3],[2,3,4],[1,2,5],[5,2,3]], target = [5,5,5]"
|
||||||
|
output: "true"
|
||||||
|
explanation: "Through multiple merge operations, we can form [5,5,5] by combining values from different triplets."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Imagine you have a collection of puzzle pieces, where each piece has three slots that can only increase in value when combined with another piece. Your goal is to assemble the exact target combination.
|
||||||
|
|
||||||
|
The key insight is that the `max` operation is **monotonically increasing** — once a value in a position exceeds the target, it can never decrease. This means if any triplet has *any* value greater than the corresponding target value, that triplet is **poisonous** and cannot be used at all.
|
||||||
|
|
||||||
|
Think of it like this: you're collecting "good" triplets that don't exceed the target in any position. From these safe triplets, you need to find ones that can contribute each target value. If triplet A has the correct first value, triplet B has the correct second value, and triplet C has the correct third value — you can merge them all together (in any order) to get the target.
|
||||||
|
|
||||||
|
The order of merging doesn't matter because `max` is both **commutative** and **associative**. You just need to verify that *somewhere* among the valid triplets, each target position's exact value exists.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using a **Greedy Filter-and-Check** approach:
|
||||||
|
|
||||||
|
**Step 1: Initialise tracking variables**
|
||||||
|
|
||||||
|
- `found`: A set or three boolean flags to track which target positions we've matched
|
||||||
|
- We need to find triplets that can contribute `target[0]`, `target[1]`, and `target[2]`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Filter and check each triplet**
|
||||||
|
|
||||||
|
- For each triplet `[a, b, c]`, first check if it's **valid** (not poisonous)
|
||||||
|
- A triplet is valid only if `a <= target[0]` AND `b <= target[1]` AND `c <= target[2]`
|
||||||
|
- If any value exceeds its target counterpart, skip this triplet entirely
|
||||||
|
- For valid triplets, check which positions exactly match the target and mark them as found
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Return the result**
|
||||||
|
|
||||||
|
- Return `true` if all three target positions have been matched by at least one valid triplet
|
||||||
|
- Return `false` otherwise
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
This works because we can always merge all valid triplets together. If each target value appears in at least one valid triplet, the final merged result will contain exactly those values (since `max` of equal values is the value itself, and we filtered out anything larger).
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Using Invalid Triplets
|
||||||
|
description: |
|
||||||
|
A critical mistake is trying to use a triplet that exceeds the target in any position.
|
||||||
|
|
||||||
|
For example, with `target = [2, 7, 5]` and a triplet `[1, 8, 4]`, you might think "I can use this for the third position since 4 <= 5." But the second position has 8 > 7, so merging this triplet would **permanently corrupt** the result — you'd end up with at least 8 in position 2, which exceeds the target.
|
||||||
|
|
||||||
|
Always check *all three* positions before considering a triplet valid.
|
||||||
|
wrong_approach: "Check positions independently without validating the whole triplet"
|
||||||
|
correct_approach: "Filter out triplets where ANY position exceeds the target"
|
||||||
|
|
||||||
|
- title: Simulating the Merge Process
|
||||||
|
description: |
|
||||||
|
Some approaches try to actually simulate merging triplets step by step, tracking the current state. This is unnecessary and complicates the solution.
|
||||||
|
|
||||||
|
Since `max` is commutative and associative, the order of operations doesn't matter. You don't need to track intermediate states — just verify that the required values *exist* among valid triplets.
|
||||||
|
wrong_approach: "Simulate merging operations and track state"
|
||||||
|
correct_approach: "Simply check if each target value exists in at least one valid triplet"
|
||||||
|
|
||||||
|
- title: Missing Values in Triplets
|
||||||
|
description: |
|
||||||
|
If the target requires a value that doesn't appear in any valid triplet, it's impossible to construct.
|
||||||
|
|
||||||
|
For example, with `target = [3, 2, 5]` and triplets `[[3,4,5],[4,5,6]]`, the value `2` doesn't exist anywhere. Even though triplet `[3,4,5]` has `3` and `5`, neither triplet can contribute `2`. Since `max` can only maintain or increase values, you cannot create a value smaller than anything present.
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Greedy filtering**: When operations are monotonic (like `max`), filter out elements that would make the goal impossible before processing"
|
||||||
|
- "**Decomposition insight**: Complex operations can sometimes be reduced to simpler existence checks — here, we just need to verify each target component exists in some valid triplet"
|
||||||
|
- "**Commutative operations**: When operation order doesn't matter, you don't need to simulate the process — just verify the preconditions"
|
||||||
|
- "**Foundation for similar problems**: This pattern of filtering invalid candidates and checking coverage applies to many greedy problems"
|
||||||
|
|
||||||
|
time_complexity: "O(n). We iterate through the triplets array once, performing constant-time checks for each triplet."
|
||||||
|
space_complexity: "O(1). We only use a fixed number of boolean variables to track which target positions have been matched."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Greedy Filter-and-Check
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def merge_triplets(triplets: list[list[int]], target: list[int]) -> bool:
|
||||||
|
# Track which target positions we've matched
|
||||||
|
found = [False, False, False]
|
||||||
|
|
||||||
|
for triplet in triplets:
|
||||||
|
# Skip triplets that exceed target in any position (poisonous)
|
||||||
|
if (triplet[0] > target[0] or
|
||||||
|
triplet[1] > target[1] or
|
||||||
|
triplet[2] > target[2]):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# This triplet is valid - check which positions match exactly
|
||||||
|
for i in range(3):
|
||||||
|
if triplet[i] == target[i]:
|
||||||
|
found[i] = True
|
||||||
|
|
||||||
|
# Return true if all three positions have been matched
|
||||||
|
return all(found)
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — Single pass through the triplets array.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — Only three boolean flags used.
|
||||||
|
|
||||||
|
We filter out invalid triplets (those exceeding target in any position) and track which target values we've found among the valid ones. If all three positions can be matched, we can merge the contributing triplets to form the target.
|
||||||
|
|
||||||
|
- approach_name: Set-Based Tracking
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def merge_triplets(triplets: list[list[int]], target: list[int]) -> bool:
|
||||||
|
# Use a set to track which positions we can match
|
||||||
|
matched = set()
|
||||||
|
|
||||||
|
for a, b, c in triplets:
|
||||||
|
# Skip if any value exceeds the target
|
||||||
|
if a > target[0] or b > target[1] or c > target[2]:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Add indices where this triplet matches the target
|
||||||
|
if a == target[0]:
|
||||||
|
matched.add(0)
|
||||||
|
if b == target[1]:
|
||||||
|
matched.add(1)
|
||||||
|
if c == target[2]:
|
||||||
|
matched.add(2)
|
||||||
|
|
||||||
|
# Early exit if we've found all three
|
||||||
|
if len(matched) == 3:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return len(matched) == 3
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — Single pass, with early exit optimisation.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — Set contains at most 3 elements.
|
||||||
|
|
||||||
|
This variant uses a set for cleaner tracking and includes an early exit when all three positions are matched. The early exit can provide a speedup when valid triplets appear early in the array.
|
||||||
|
|
||||||
|
- approach_name: Brute Force Simulation
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def merge_triplets(triplets: list[list[int]], target: list[int]) -> bool:
|
||||||
|
n = len(triplets)
|
||||||
|
|
||||||
|
# Try all possible subsets of triplets to merge
|
||||||
|
for mask in range(1, 1 << n):
|
||||||
|
# Start with zeros and merge selected triplets
|
||||||
|
result = [0, 0, 0]
|
||||||
|
|
||||||
|
for i in range(n):
|
||||||
|
if mask & (1 << i):
|
||||||
|
result[0] = max(result[0], triplets[i][0])
|
||||||
|
result[1] = max(result[1], triplets[i][1])
|
||||||
|
result[2] = max(result[2], triplets[i][2])
|
||||||
|
|
||||||
|
if result == target:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(2^n * n) — Exponential in the number of triplets.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — Only storing the current merged result.
|
||||||
|
|
||||||
|
This brute force approach tries all possible subsets of triplets to merge. While correct, it's far too slow for the constraint `n <= 10^5` — it would require up to 2^100000 iterations! Included here to illustrate why the greedy insight is essential.
|
||||||
177
backend/data/questions/min-cost-climbing-stairs.yaml
Normal file
177
backend/data/questions/min-cost-climbing-stairs.yaml
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
title: Min Cost Climbing Stairs
|
||||||
|
slug: min-cost-climbing-stairs
|
||||||
|
difficulty: easy
|
||||||
|
leetcode_id: 746
|
||||||
|
leetcode_url: https://leetcode.com/problems/min-cost-climbing-stairs/
|
||||||
|
categories:
|
||||||
|
- arrays
|
||||||
|
- dynamic-programming
|
||||||
|
patterns:
|
||||||
|
- dynamic-programming
|
||||||
|
|
||||||
|
description: |
|
||||||
|
You are given an integer array `cost` where `cost[i]` is the cost of the i<sup>th</sup> step on a staircase. Once you pay the cost, you can either climb one or two steps.
|
||||||
|
|
||||||
|
You can either start from the step with index `0`, or the step with index `1`.
|
||||||
|
|
||||||
|
Return *the minimum cost to reach the top of the floor*.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `2 <= cost.length <= 1000`
|
||||||
|
- `0 <= cost[i] <= 999`
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: "cost = [10, 15, 20]"
|
||||||
|
output: "15"
|
||||||
|
explanation: "Start at index 1. Pay 15 and climb two steps to reach the top. Total cost is 15."
|
||||||
|
- input: "cost = [1, 100, 1, 1, 1, 100, 1, 1, 100, 1]"
|
||||||
|
output: "6"
|
||||||
|
explanation: "Start at index 0. Pay costs at indices 0, 2, 4, 6, 7, 9 (all value 1), climbing strategically to avoid the 100-cost steps. Total cost is 6."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Imagine you're climbing a staircase where each step has a toll booth. You must pay the toll to leave that step, and then you can jump either one or two steps forward. The goal is to reach the top (past the last step) while paying as little as possible.
|
||||||
|
|
||||||
|
Think of it like this: standing at any step, you ask yourself "What's the **cheapest way** to have arrived here?" You could have come from one step back (paying that step's cost) or from two steps back (paying that step's cost). The minimum of these two options gives you the cheapest path to your current position.
|
||||||
|
|
||||||
|
This is the **principle of optimality** — the optimal solution to the whole problem contains optimal solutions to subproblems. If you know the minimum cost to reach steps `i-1` and `i-2`, you can easily compute the minimum cost to reach step `i`.
|
||||||
|
|
||||||
|
The key insight is that you don't need to track the actual path taken — only the **cumulative minimum cost** to reach each position. This transforms a potentially exponential problem (trying all paths) into a linear one.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using **Space-Optimised Dynamic Programming**:
|
||||||
|
|
||||||
|
**Step 1: Understand the goal**
|
||||||
|
|
||||||
|
- The "top" is one position past the last step (index `n`)
|
||||||
|
- You can start at index `0` or `1` without paying anything initially
|
||||||
|
- You must pay `cost[i]` to leave step `i` and climb further
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Define the recurrence**
|
||||||
|
|
||||||
|
- Let `dp[i]` = minimum cost to reach step `i`
|
||||||
|
- `dp[0] = 0`: Starting at step 0 costs nothing (you haven't climbed yet)
|
||||||
|
- `dp[1] = 0`: Starting at step 1 costs nothing (alternative starting point)
|
||||||
|
- For `i >= 2`: `dp[i] = min(dp[i-1] + cost[i-1], dp[i-2] + cost[i-2])`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Optimise space**
|
||||||
|
|
||||||
|
- Since we only need the last two values, use two variables instead of an array
|
||||||
|
- `prev1`: Minimum cost to reach position `i-1`
|
||||||
|
- `prev2`: Minimum cost to reach position `i-2`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 4: Iterate to the top**
|
||||||
|
|
||||||
|
- Loop from position `2` to `n` (the top, past the last step)
|
||||||
|
- At each position, calculate the minimum cost to arrive there
|
||||||
|
- Update the sliding window: shift `prev2 = prev1`, `prev1 = current`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 5: Return the result**
|
||||||
|
|
||||||
|
- After the loop, `prev1` contains the minimum cost to reach the top
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Confusing "Reach Step" vs "Leave Step"
|
||||||
|
description: |
|
||||||
|
A common mistake is confusing when you pay the cost. You pay `cost[i]` when you **leave** step `i`, not when you arrive.
|
||||||
|
|
||||||
|
This means:
|
||||||
|
- Arriving at step `i` doesn't require paying `cost[i]` yet
|
||||||
|
- The minimum cost to reach step `i` is `min(cost_to_reach[i-1] + cost[i-1], cost_to_reach[i-2] + cost[i-2])`
|
||||||
|
- The "top" is at index `n`, one past the last element
|
||||||
|
|
||||||
|
Getting this wrong leads to off-by-one errors and incorrect totals.
|
||||||
|
wrong_approach: "dp[i] = min(dp[i-1], dp[i-2]) + cost[i]"
|
||||||
|
correct_approach: "dp[i] = min(dp[i-1] + cost[i-1], dp[i-2] + cost[i-2])"
|
||||||
|
|
||||||
|
- title: Forgetting You Can Start at Index 0 or 1
|
||||||
|
description: |
|
||||||
|
The problem states you can start at either index `0` or `1`. This means:
|
||||||
|
- `dp[0] = 0` (no cost to be at starting position 0)
|
||||||
|
- `dp[1] = 0` (no cost to be at starting position 1)
|
||||||
|
|
||||||
|
If you incorrectly set `dp[0] = cost[0]` or `dp[1] = cost[1]`, you're charging for arriving at a starting position, which isn't required.
|
||||||
|
wrong_approach: "dp[0] = cost[0], dp[1] = cost[1]"
|
||||||
|
correct_approach: "dp[0] = 0, dp[1] = 0 (starting positions are free)"
|
||||||
|
|
||||||
|
- title: Using O(n) Space Unnecessarily
|
||||||
|
description: |
|
||||||
|
While an array `dp[0..n]` works correctly, you only ever need the last two computed values. Storing the entire array wastes space.
|
||||||
|
|
||||||
|
With `n` up to 1000, this isn't critical for correctness, but the space-optimised O(1) solution is cleaner and demonstrates good DP technique.
|
||||||
|
wrong_approach: "Maintaining full dp array of size n+1"
|
||||||
|
correct_approach: "Use two variables to track only the last two values"
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**State definition matters**: Clearly define what your DP state represents — 'cost to reach' vs 'cost to leave' changes the recurrence"
|
||||||
|
- "**Space optimisation**: When the recurrence only depends on fixed previous states (here, the last 2), use variables instead of an array"
|
||||||
|
- "**Extension of Climbing Stairs**: This problem adds a cost dimension to the classic stair-climbing problem — recognise this pattern"
|
||||||
|
- "**Bottom-up efficiency**: Iterative DP avoids recursion overhead and makes the space optimisation natural"
|
||||||
|
|
||||||
|
time_complexity: "O(n). We iterate once from position 2 to n, performing O(1) work at each step."
|
||||||
|
space_complexity: "O(1). We only store two variables (`prev1` and `prev2`), regardless of input size."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Space-Optimised DP
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def min_cost_climbing_stairs(cost: list[int]) -> int:
|
||||||
|
n = len(cost)
|
||||||
|
|
||||||
|
# Minimum cost to reach step 0 or 1 (starting positions)
|
||||||
|
prev2 = 0 # cost to reach position i-2
|
||||||
|
prev1 = 0 # cost to reach position i-1
|
||||||
|
|
||||||
|
# Calculate minimum cost to reach each position up to the top
|
||||||
|
for i in range(2, n + 1):
|
||||||
|
# To reach position i, either:
|
||||||
|
# - Come from i-1, paying cost[i-1] to leave that step
|
||||||
|
# - Come from i-2, paying cost[i-2] to leave that step
|
||||||
|
current = min(prev1 + cost[i - 1], prev2 + cost[i - 2])
|
||||||
|
|
||||||
|
# Slide the window forward
|
||||||
|
prev2 = prev1
|
||||||
|
prev1 = current
|
||||||
|
|
||||||
|
return prev1
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — Single pass through the array.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — Only two variables needed.
|
||||||
|
|
||||||
|
We iterate from position 2 to n (the top), computing the minimum cost to reach each position based on the two previous positions. The final value gives us the minimum cost to reach the top.
|
||||||
|
|
||||||
|
- approach_name: DP with Array
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def min_cost_climbing_stairs(cost: list[int]) -> int:
|
||||||
|
n = len(cost)
|
||||||
|
|
||||||
|
# dp[i] = minimum cost to reach position i
|
||||||
|
dp = [0] * (n + 1)
|
||||||
|
|
||||||
|
# Starting positions are free
|
||||||
|
dp[0] = 0
|
||||||
|
dp[1] = 0
|
||||||
|
|
||||||
|
# Fill in minimum cost for each position
|
||||||
|
for i in range(2, n + 1):
|
||||||
|
# Choose the cheaper path to reach position i
|
||||||
|
dp[i] = min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2])
|
||||||
|
|
||||||
|
# Return cost to reach the top (position n)
|
||||||
|
return dp[n]
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — Single pass to fill the DP array.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n) — Array of size n+1 to store intermediate costs.
|
||||||
|
|
||||||
|
This approach explicitly stores all intermediate results in an array. While correct, it uses more space than necessary since we only need the last two values at any point. Useful for understanding the DP structure before applying space optimisation.
|
||||||
218
backend/data/questions/min-cost-to-connect-all-points.yaml
Normal file
218
backend/data/questions/min-cost-to-connect-all-points.yaml
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
title: Min Cost to Connect All Points
|
||||||
|
slug: min-cost-to-connect-all-points
|
||||||
|
difficulty: medium
|
||||||
|
leetcode_id: 1584
|
||||||
|
leetcode_url: https://leetcode.com/problems/min-cost-to-connect-all-points/
|
||||||
|
categories:
|
||||||
|
- graphs
|
||||||
|
- heap
|
||||||
|
patterns:
|
||||||
|
- heap
|
||||||
|
- union-find
|
||||||
|
|
||||||
|
description: |
|
||||||
|
You are given an array `points` representing integer coordinates of some points on a 2D-plane, where `points[i] = [x_i, y_i]`.
|
||||||
|
|
||||||
|
The cost of connecting two points `[x_i, y_i]` and `[x_j, y_j]` is the **manhattan distance** between them: `|x_i - x_j| + |y_i - y_j|`, where `|val|` denotes the absolute value of `val`.
|
||||||
|
|
||||||
|
Return *the minimum cost to make all points connected*. All points are connected if there is **exactly one** simple path between any two points.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `1 <= points.length <= 1000`
|
||||||
|
- `-10^6 <= x_i, y_i <= 10^6`
|
||||||
|
- All pairs `(x_i, y_i)` are distinct
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: "points = [[0,0],[2,2],[3,10],[5,2],[7,0]]"
|
||||||
|
output: "20"
|
||||||
|
explanation: "We can connect points with edges of total cost 20. Notice that there is a unique path between every pair of points."
|
||||||
|
- input: "points = [[3,12],[-2,5],[-4,1]]"
|
||||||
|
output: "18"
|
||||||
|
explanation: "Connect the three points with edges to form a tree of minimum total cost."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
This problem is asking us to connect all points with the **minimum total edge cost** such that any point can reach any other point. This is exactly the definition of a **Minimum Spanning Tree (MST)**.
|
||||||
|
|
||||||
|
Think of it like this: imagine you're a city planner laying down cables between houses. Each house is a point, and the cost of laying cable between two houses is the manhattan distance. You want to connect all houses while spending as little as possible on cable.
|
||||||
|
|
||||||
|
The key insight is that to connect `n` points, we need exactly `n - 1` edges (any more would create a cycle, any fewer would leave points disconnected). Among all possible ways to choose `n - 1` edges that connect everything, we want the one with minimum total weight.
|
||||||
|
|
||||||
|
Two classic algorithms solve this:
|
||||||
|
- **Prim's Algorithm**: Start from one point and greedily add the cheapest edge that connects a new point to our growing tree
|
||||||
|
- **Kruskal's Algorithm**: Sort all edges by cost and greedily add them if they don't create a cycle
|
||||||
|
|
||||||
|
Since the graph is **dense** (every point can connect to every other point, giving us `n(n-1)/2` edges), Prim's algorithm with a min-heap is typically more efficient here.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We'll use **Prim's Algorithm** with a min-heap to build the MST efficiently.
|
||||||
|
|
||||||
|
**Step 1: Initialise data structures**
|
||||||
|
|
||||||
|
- `total_cost`: Set to `0` to accumulate the MST weight
|
||||||
|
- `visited`: A set to track which points are already in our MST
|
||||||
|
- `min_heap`: Priority queue storing `(cost, point_index)` tuples, initialised with `(0, 0)` to start from point 0
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Build the MST greedily**
|
||||||
|
|
||||||
|
- While we haven't connected all `n` points:
|
||||||
|
- Pop the minimum cost edge from the heap
|
||||||
|
- If this point is already visited, skip it (we found a cheaper path earlier)
|
||||||
|
- Otherwise, add this point to the MST: mark as visited, add the edge cost to `total_cost`
|
||||||
|
- For each unvisited point, calculate the manhattan distance and push `(distance, point_index)` to the heap
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Return the result**
|
||||||
|
|
||||||
|
- Return `total_cost` once all `n` points are connected
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
The min-heap ensures we always process the cheapest available edge first, guaranteeing we build an optimal MST.
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Using Adjacency List for Dense Graph
|
||||||
|
description: |
|
||||||
|
A common instinct is to precompute all edges and store them in an adjacency list. With `n` points, this creates `n(n-1)/2` edges, using O(n^2) space.
|
||||||
|
|
||||||
|
For this problem with `n <= 1000`, that's about 500,000 edges which is manageable. However, Prim's algorithm can compute edge weights on-the-fly, avoiding the upfront memory cost while achieving the same time complexity.
|
||||||
|
wrong_approach: "Precompute and store all O(n^2) edges"
|
||||||
|
correct_approach: "Compute manhattan distance on-demand during Prim's traversal"
|
||||||
|
|
||||||
|
- title: Forgetting to Check Visited Before Processing
|
||||||
|
description: |
|
||||||
|
When popping from the heap, the same point might appear multiple times with different costs (we pushed it once for each neighbor that discovered it). Always check if a point is already in the MST before processing.
|
||||||
|
|
||||||
|
Processing a visited point would add duplicate edges and inflate the total cost.
|
||||||
|
wrong_approach: "Process every heap entry without checking visited"
|
||||||
|
correct_approach: "Skip heap entries for already-visited points"
|
||||||
|
|
||||||
|
- title: Off-by-One in Edge Count
|
||||||
|
description: |
|
||||||
|
An MST connecting `n` nodes has exactly `n - 1` edges. Some implementations track edge count to know when to stop. If you're counting edges, ensure you stop at `n - 1`, not `n`.
|
||||||
|
|
||||||
|
Using a visited set with size check `len(visited) == n` avoids this issue entirely.
|
||||||
|
wrong_approach: "Stop when edge_count == n"
|
||||||
|
correct_approach: "Stop when len(visited) == n or edge_count == n - 1"
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Minimum Spanning Tree**: When connecting nodes with minimum cost and no cycles, think MST algorithms (Prim's or Kruskal's)"
|
||||||
|
- "**Dense vs Sparse graphs**: Prim's with a heap is O(E log V), which is efficient for dense graphs where E approaches V^2"
|
||||||
|
- "**On-demand computation**: For fully connected graphs, compute edge weights as needed rather than storing them all"
|
||||||
|
- "**Heap for greedy selection**: Min-heaps efficiently find the next best edge in O(log n) time"
|
||||||
|
|
||||||
|
time_complexity: "O(n^2 log n). We potentially push O(n^2) edges to the heap, and each heap operation is O(log n). Alternatively, O(n^2) using Prim's with an array instead of a heap."
|
||||||
|
space_complexity: "O(n). We store the visited set of size n and the heap can grow up to O(n) entries at a time (we only push edges to unvisited nodes)."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Prim's Algorithm with Min-Heap
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
import heapq
|
||||||
|
|
||||||
|
def min_cost_connect_points(points: list[list[int]]) -> int:
|
||||||
|
n = len(points)
|
||||||
|
if n <= 1:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Track which points are in our MST
|
||||||
|
visited = set()
|
||||||
|
# Min-heap: (cost, point_index)
|
||||||
|
# Start from point 0 with cost 0
|
||||||
|
min_heap = [(0, 0)]
|
||||||
|
total_cost = 0
|
||||||
|
|
||||||
|
while len(visited) < n:
|
||||||
|
# Get the cheapest edge to an unvisited point
|
||||||
|
cost, curr = heapq.heappop(min_heap)
|
||||||
|
|
||||||
|
# Skip if already in MST (found a cheaper path earlier)
|
||||||
|
if curr in visited:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Add this point to MST
|
||||||
|
visited.add(curr)
|
||||||
|
total_cost += cost
|
||||||
|
|
||||||
|
# Explore edges to all unvisited points
|
||||||
|
for next_point in range(n):
|
||||||
|
if next_point not in visited:
|
||||||
|
# Calculate manhattan distance
|
||||||
|
dist = (abs(points[curr][0] - points[next_point][0]) +
|
||||||
|
abs(points[curr][1] - points[next_point][1]))
|
||||||
|
heapq.heappush(min_heap, (dist, next_point))
|
||||||
|
|
||||||
|
return total_cost
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n^2 log n) — We push up to O(n^2) edges to the heap, each operation is O(log n).
|
||||||
|
|
||||||
|
**Space Complexity:** O(n) — The visited set is O(n), and the heap stores at most one entry per unvisited node at any time in the worst case.
|
||||||
|
|
||||||
|
This is the standard Prim's algorithm implementation. We greedily select the minimum cost edge that adds a new point to our growing MST, continuing until all points are connected.
|
||||||
|
|
||||||
|
- approach_name: Kruskal's Algorithm with Union-Find
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
class UnionFind:
|
||||||
|
def __init__(self, n: int):
|
||||||
|
self.parent = list(range(n))
|
||||||
|
self.rank = [0] * n
|
||||||
|
|
||||||
|
def find(self, x: int) -> int:
|
||||||
|
# Path compression
|
||||||
|
if self.parent[x] != x:
|
||||||
|
self.parent[x] = self.find(self.parent[x])
|
||||||
|
return self.parent[x]
|
||||||
|
|
||||||
|
def union(self, x: int, y: int) -> bool:
|
||||||
|
# Union by rank, returns True if merged
|
||||||
|
px, py = self.find(x), self.find(y)
|
||||||
|
if px == py:
|
||||||
|
return False
|
||||||
|
if self.rank[px] < self.rank[py]:
|
||||||
|
px, py = py, px
|
||||||
|
self.parent[py] = px
|
||||||
|
if self.rank[px] == self.rank[py]:
|
||||||
|
self.rank[px] += 1
|
||||||
|
return True
|
||||||
|
|
||||||
|
def min_cost_connect_points(points: list[list[int]]) -> int:
|
||||||
|
n = len(points)
|
||||||
|
if n <= 1:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Generate all edges: (cost, point_i, point_j)
|
||||||
|
edges = []
|
||||||
|
for i in range(n):
|
||||||
|
for j in range(i + 1, n):
|
||||||
|
dist = (abs(points[i][0] - points[j][0]) +
|
||||||
|
abs(points[i][1] - points[j][1]))
|
||||||
|
edges.append((dist, i, j))
|
||||||
|
|
||||||
|
# Sort edges by cost
|
||||||
|
edges.sort()
|
||||||
|
|
||||||
|
# Build MST using Union-Find
|
||||||
|
uf = UnionFind(n)
|
||||||
|
total_cost = 0
|
||||||
|
edges_used = 0
|
||||||
|
|
||||||
|
for cost, u, v in edges:
|
||||||
|
# Only add edge if it connects two components
|
||||||
|
if uf.union(u, v):
|
||||||
|
total_cost += cost
|
||||||
|
edges_used += 1
|
||||||
|
# MST complete when we have n-1 edges
|
||||||
|
if edges_used == n - 1:
|
||||||
|
break
|
||||||
|
|
||||||
|
return total_cost
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n^2 log n) — Generating edges is O(n^2), sorting is O(n^2 log n), and Union-Find operations are nearly O(1) amortized.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n^2) — We store all n(n-1)/2 edges before sorting.
|
||||||
|
|
||||||
|
Kruskal's algorithm sorts all edges and greedily adds them if they don't create a cycle. Union-Find efficiently detects cycles. This approach uses more memory due to storing all edges but is conceptually simpler.
|
||||||
241
backend/data/questions/min-stack.yaml
Normal file
241
backend/data/questions/min-stack.yaml
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
title: Min Stack
|
||||||
|
slug: min-stack
|
||||||
|
difficulty: medium
|
||||||
|
leetcode_id: 155
|
||||||
|
leetcode_url: https://leetcode.com/problems/min-stack/
|
||||||
|
categories:
|
||||||
|
- stack
|
||||||
|
patterns:
|
||||||
|
- monotonic-stack
|
||||||
|
|
||||||
|
description: |
|
||||||
|
Design a stack that supports push, pop, top, and retrieving the minimum element in constant time.
|
||||||
|
|
||||||
|
Implement the `MinStack` class:
|
||||||
|
|
||||||
|
- `MinStack()` initialises the stack object.
|
||||||
|
- `void push(int val)` pushes the element `val` onto the stack.
|
||||||
|
- `void pop()` removes the element on the top of the stack.
|
||||||
|
- `int top()` gets the top element of the stack.
|
||||||
|
- `int getMin()` retrieves the minimum element in the stack.
|
||||||
|
|
||||||
|
You must implement a solution with **O(1) time complexity** for each function.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `-2^31 <= val <= 2^31 - 1`
|
||||||
|
- Methods `pop`, `top` and `getMin` operations will always be called on **non-empty** stacks.
|
||||||
|
- At most `3 * 10^4` calls will be made to `push`, `pop`, `top`, and `getMin`.
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: |
|
||||||
|
["MinStack","push","push","push","getMin","pop","top","getMin"]
|
||||||
|
[[],[-2],[0],[-3],[],[],[],[]]
|
||||||
|
output: "[null,null,null,null,-3,null,0,-2]"
|
||||||
|
explanation: |
|
||||||
|
MinStack minStack = new MinStack();
|
||||||
|
minStack.push(-2);
|
||||||
|
minStack.push(0);
|
||||||
|
minStack.push(-3);
|
||||||
|
minStack.getMin(); // return -3
|
||||||
|
minStack.pop();
|
||||||
|
minStack.top(); // return 0
|
||||||
|
minStack.getMin(); // return -2
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Think of this problem like maintaining two parallel records: one for the actual items on the stack, and one for "what's the smallest item from here down?"
|
||||||
|
|
||||||
|
A regular stack gives us O(1) for push, pop, and top — those operations only touch the top element. But finding the minimum normally requires scanning the entire stack, which is O(n).
|
||||||
|
|
||||||
|
The key insight is that we can **precompute the minimum at each level** of the stack. When we push a new element, we know the current minimum (from the previous level) and the new value — the new minimum is simply the smaller of the two. When we pop, the minimum "reverts" to whatever it was before we pushed that element.
|
||||||
|
|
||||||
|
Imagine each element carrying a "badge" that says: "When I'm on top, the minimum is X." This badge never changes once assigned, because elements below me will never change while I exist on the stack.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using **two synchronised stacks**: one for values, one for minimums.
|
||||||
|
|
||||||
|
**Step 1: Design the data structure**
|
||||||
|
|
||||||
|
- `stack`: A standard stack storing all pushed values
|
||||||
|
- `min_stack`: A parallel stack where each entry holds the minimum value at that level
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Implement push**
|
||||||
|
|
||||||
|
- Push `val` onto `stack`
|
||||||
|
- Compute the new minimum: `min(val, current_min)` where `current_min` is `min_stack[-1]` (or `val` if empty)
|
||||||
|
- Push this minimum onto `min_stack`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Implement pop**
|
||||||
|
|
||||||
|
- Pop from both `stack` and `min_stack` simultaneously
|
||||||
|
- This maintains the synchronisation — when the top value leaves, so does its associated minimum
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 4: Implement top and getMin**
|
||||||
|
|
||||||
|
- `top()`: Return `stack[-1]`
|
||||||
|
- `getMin()`: Return `min_stack[-1]`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Both stacks always have the same length, so `getMin()` always reflects the minimum considering all elements currently in the stack.
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Scanning for Minimum on Every Call
|
||||||
|
description: |
|
||||||
|
A naive approach is to iterate through the entire stack each time `getMin()` is called:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def getMin(self):
|
||||||
|
return min(self.stack) # O(n) - too slow!
|
||||||
|
```
|
||||||
|
|
||||||
|
This violates the O(1) requirement. With up to `3 * 10^4` operations, repeatedly scanning could result in O(n²) total time.
|
||||||
|
wrong_approach: "Scanning the stack on each getMin() call"
|
||||||
|
correct_approach: "Track minimum at each stack level"
|
||||||
|
|
||||||
|
- title: Single Variable for Minimum
|
||||||
|
description: |
|
||||||
|
Storing only a single `self.min_value` variable seems efficient, but fails on pop:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def pop(self):
|
||||||
|
if self.stack[-1] == self.min_value:
|
||||||
|
# What's the new minimum? We don't know!
|
||||||
|
self.min_value = ???
|
||||||
|
```
|
||||||
|
|
||||||
|
When you pop the current minimum, you'd need to scan the remaining stack to find the new minimum — back to O(n).
|
||||||
|
wrong_approach: "Single variable tracking current minimum"
|
||||||
|
correct_approach: "Stack of minimums to restore previous min on pop"
|
||||||
|
|
||||||
|
- title: Forgetting Empty Stack Edge Case in Push
|
||||||
|
description: |
|
||||||
|
When the stack is empty, there's no previous minimum to compare against:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def push(self, val):
|
||||||
|
# This crashes if min_stack is empty!
|
||||||
|
new_min = min(val, self.min_stack[-1])
|
||||||
|
```
|
||||||
|
|
||||||
|
Handle the empty case by treating the first element as the minimum.
|
||||||
|
wrong_approach: "Always accessing min_stack[-1]"
|
||||||
|
correct_approach: "Check if min_stack is empty first, or use infinity as initial comparison"
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Auxiliary data structures**: When one operation is too slow, consider storing precomputed information in a parallel structure"
|
||||||
|
- "**State at each level**: The minimum-at-each-level technique extends to other 'aggregate' queries (max, sum, etc.)"
|
||||||
|
- "**Space-time tradeoff**: We use O(n) extra space to achieve O(1) time for all operations"
|
||||||
|
- "**Design problems**: Often require thinking about invariants — here, `min_stack[i]` always equals `min(stack[0:i+1])`"
|
||||||
|
|
||||||
|
time_complexity: "O(1) for all operations. Push, pop, top, and getMin each perform a constant number of operations."
|
||||||
|
space_complexity: "O(n) where `n` is the number of elements in the stack. We store each element twice — once in the main stack and once in the min stack."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Two Stacks
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
class MinStack:
|
||||||
|
def __init__(self):
|
||||||
|
# Main stack for all values
|
||||||
|
self.stack = []
|
||||||
|
# Parallel stack tracking minimum at each level
|
||||||
|
self.min_stack = []
|
||||||
|
|
||||||
|
def push(self, val: int) -> None:
|
||||||
|
self.stack.append(val)
|
||||||
|
# New minimum is smaller of val and current min (or just val if empty)
|
||||||
|
if self.min_stack:
|
||||||
|
new_min = min(val, self.min_stack[-1])
|
||||||
|
else:
|
||||||
|
new_min = val
|
||||||
|
self.min_stack.append(new_min)
|
||||||
|
|
||||||
|
def pop(self) -> None:
|
||||||
|
# Remove from both stacks to stay synchronised
|
||||||
|
self.stack.pop()
|
||||||
|
self.min_stack.pop()
|
||||||
|
|
||||||
|
def top(self) -> int:
|
||||||
|
return self.stack[-1]
|
||||||
|
|
||||||
|
def getMin(self) -> int:
|
||||||
|
# min_stack top always holds current minimum
|
||||||
|
return self.min_stack[-1]
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(1) for all operations — each method performs constant-time stack operations.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n) — we store each element twice.
|
||||||
|
|
||||||
|
The min_stack maintains an invariant: `min_stack[i]` equals the minimum value among `stack[0]` through `stack[i]`. When we pop, both stacks shrink together, automatically restoring the correct minimum.
|
||||||
|
|
||||||
|
- approach_name: Single Stack with Pairs
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
class MinStack:
|
||||||
|
def __init__(self):
|
||||||
|
# Each entry is (value, min_at_this_level)
|
||||||
|
self.stack = []
|
||||||
|
|
||||||
|
def push(self, val: int) -> None:
|
||||||
|
# Calculate minimum including this new value
|
||||||
|
if self.stack:
|
||||||
|
current_min = min(val, self.stack[-1][1])
|
||||||
|
else:
|
||||||
|
current_min = val
|
||||||
|
# Store both the value and the running minimum
|
||||||
|
self.stack.append((val, current_min))
|
||||||
|
|
||||||
|
def pop(self) -> None:
|
||||||
|
self.stack.pop()
|
||||||
|
|
||||||
|
def top(self) -> int:
|
||||||
|
return self.stack[-1][0]
|
||||||
|
|
||||||
|
def getMin(self) -> int:
|
||||||
|
return self.stack[-1][1]
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(1) for all operations.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n) — each stack entry stores two values.
|
||||||
|
|
||||||
|
This variation combines both pieces of information into a single stack. Each entry is a tuple of `(value, minimum_at_this_level)`. Functionally equivalent to the two-stack approach, but with slightly cleaner code.
|
||||||
|
|
||||||
|
- approach_name: Min Stack with Optimised Space
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
class MinStack:
|
||||||
|
def __init__(self):
|
||||||
|
self.stack = []
|
||||||
|
# Only store min when it changes
|
||||||
|
self.min_stack = []
|
||||||
|
|
||||||
|
def push(self, val: int) -> None:
|
||||||
|
self.stack.append(val)
|
||||||
|
# Only push to min_stack if val is <= current minimum
|
||||||
|
if not self.min_stack or val <= self.min_stack[-1]:
|
||||||
|
self.min_stack.append(val)
|
||||||
|
|
||||||
|
def pop(self) -> None:
|
||||||
|
val = self.stack.pop()
|
||||||
|
# Only pop from min_stack if we're removing the current minimum
|
||||||
|
if val == self.min_stack[-1]:
|
||||||
|
self.min_stack.pop()
|
||||||
|
|
||||||
|
def top(self) -> int:
|
||||||
|
return self.stack[-1]
|
||||||
|
|
||||||
|
def getMin(self) -> int:
|
||||||
|
return self.min_stack[-1]
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(1) for all operations.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n) worst case, but often less in practice.
|
||||||
|
|
||||||
|
This optimisation only adds to `min_stack` when we see a new minimum (or equal value). The space savings depend on the input — if values are strictly increasing, `min_stack` holds only one element. Note the `<=` instead of `<`: we must track duplicates of the minimum value, otherwise popping one copy would incorrectly update the minimum.
|
||||||
220
backend/data/questions/minimum-height-trees.yaml
Normal file
220
backend/data/questions/minimum-height-trees.yaml
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
title: Minimum Height Trees
|
||||||
|
slug: minimum-height-trees
|
||||||
|
difficulty: medium
|
||||||
|
leetcode_id: 310
|
||||||
|
leetcode_url: https://leetcode.com/problems/minimum-height-trees/
|
||||||
|
categories:
|
||||||
|
- graphs
|
||||||
|
- trees
|
||||||
|
patterns:
|
||||||
|
- bfs
|
||||||
|
- tree-traversal
|
||||||
|
|
||||||
|
description: |
|
||||||
|
A tree is an undirected graph in which any two vertices are connected by *exactly* one path. In other words, any connected graph without simple cycles is a tree.
|
||||||
|
|
||||||
|
Given a tree of `n` nodes labelled from `0` to `n - 1`, and an array of `n - 1` `edges` where `edges[i] = [a_i, b_i]` indicates that there is an undirected edge between the two nodes `a_i` and `b_i` in the tree, you can choose any node of the tree as the root. When you select a node `x` as the root, the result tree has height `h`. Among all possible rooted trees, those with minimum height (i.e. `min(h)`) are called **minimum height trees** (MHTs).
|
||||||
|
|
||||||
|
Return *a list of all MHTs' root labels*. You can return the answer in **any order**.
|
||||||
|
|
||||||
|
The **height** of a rooted tree is the number of edges on the longest downward path between the root and a leaf.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `1 <= n <= 2 * 10^4`
|
||||||
|
- `edges.length == n - 1`
|
||||||
|
- `0 <= a_i, b_i < n`
|
||||||
|
- `a_i != b_i`
|
||||||
|
- All the pairs `(a_i, b_i)` are distinct
|
||||||
|
- The given input is **guaranteed** to be a tree and there will be **no repeated** edges
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: "n = 4, edges = [[1,0],[1,2],[1,3]]"
|
||||||
|
output: "[1]"
|
||||||
|
explanation: "The height of the tree is 1 when the root is node 1, which is the only MHT. If we root at any leaf (0, 2, or 3), the height would be 2."
|
||||||
|
- input: "n = 6, edges = [[3,0],[3,1],[3,2],[3,4],[5,4]]"
|
||||||
|
output: "[3,4]"
|
||||||
|
explanation: "Both nodes 3 and 4 produce trees of minimum height 2. Rooting at any other node would produce a taller tree."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Imagine a tree as a physical structure where you're trying to find the **centre of gravity** — the node(s) that minimise the maximum distance to any edge of the tree.
|
||||||
|
|
||||||
|
Here's the key insight: **leaves can never be the root of a minimum height tree** (unless the tree has only 1 or 2 nodes). Why? A leaf sits at the edge of the tree, so rooting there guarantees a long path to the opposite side.
|
||||||
|
|
||||||
|
Think of it like peeling an onion from the outside in. If we repeatedly remove all the current leaves (nodes with only one connection), we'll gradually work our way toward the centre. The last remaining node(s) — either 1 or 2 — will be the roots that minimise the tree height.
|
||||||
|
|
||||||
|
Why at most 2 nodes? In a tree, the "centre" lies on the longest path (the diameter). If the diameter has an odd number of edges, there's exactly one centre node. If even, there are two adjacent centre nodes.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using **Topological Sort (Leaf Trimming)**:
|
||||||
|
|
||||||
|
**Step 1: Handle edge cases**
|
||||||
|
|
||||||
|
- If `n == 1`, return `[0]` — a single node is its own MHT
|
||||||
|
- If `n == 2`, return `[0, 1]` — both nodes are valid MHT roots
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Build the adjacency list and track degrees**
|
||||||
|
|
||||||
|
- Create an adjacency list representing the undirected graph
|
||||||
|
- Track the degree (number of connections) of each node
|
||||||
|
- Identify initial leaves: nodes with degree 1
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Iteratively trim leaves**
|
||||||
|
|
||||||
|
- Add all current leaves to a queue
|
||||||
|
- While more than 2 nodes remain:
|
||||||
|
- Remove all current leaves from the tree
|
||||||
|
- For each removed leaf, decrement the degree of its neighbour
|
||||||
|
- If a neighbour's degree becomes 1, it's a new leaf — add it to the next round
|
||||||
|
- Repeat until 1 or 2 nodes remain
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 4: Return the remaining nodes**
|
||||||
|
|
||||||
|
- The last batch of "leaves" are the MHT roots
|
||||||
|
- These are the centre node(s) of the tree
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Brute Force TLE
|
||||||
|
description: |
|
||||||
|
A naive approach tries rooting the tree at every node and computing heights via BFS/DFS:
|
||||||
|
- For each of `n` nodes as root: O(n)
|
||||||
|
- Compute tree height: O(n)
|
||||||
|
|
||||||
|
This gives **O(n²) time complexity**. With `n <= 2 * 10^4`, this results in up to 400 million operations — likely causing TLE.
|
||||||
|
|
||||||
|
The topological sort approach runs in O(n) time by exploiting the tree structure.
|
||||||
|
wrong_approach: "BFS/DFS from every node to find minimum height"
|
||||||
|
correct_approach: "Trim leaves iteratively to find the centre"
|
||||||
|
|
||||||
|
- title: Forgetting the Single/Double Node Cases
|
||||||
|
description: |
|
||||||
|
When `n == 1`, return `[0]`. When `n == 2`, return `[0, 1]`.
|
||||||
|
|
||||||
|
These edge cases break the general algorithm since there are no "leaves" to trim in the traditional sense, or the leaves ARE the answer.
|
||||||
|
wrong_approach: "Assume n >= 3 and skip edge cases"
|
||||||
|
correct_approach: "Handle n == 1 and n == 2 explicitly"
|
||||||
|
|
||||||
|
- title: Using Directed Graph Logic
|
||||||
|
description: |
|
||||||
|
Trees are **undirected** graphs. When trimming a leaf, you must update the degree of its neighbour by removing the leaf from the neighbour's adjacency set.
|
||||||
|
|
||||||
|
If you only track one direction, you'll incorrectly identify which nodes become leaves after removal.
|
||||||
|
wrong_approach: "Track edges in only one direction"
|
||||||
|
correct_approach: "Maintain bidirectional adjacency and update both sides"
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Tree centrality**: The MHT roots are the 'centre' of the tree — the node(s) that minimise maximum distance to any leaf"
|
||||||
|
- "**Leaf trimming pattern**: Repeatedly removing leaves reveals the structural centre — useful for finding tree diameters and centres"
|
||||||
|
- "**At most 2 centres**: A tree always has 1 or 2 centre nodes, lying on its diameter"
|
||||||
|
- "**O(n) efficiency**: By processing each node and edge exactly once, we avoid the quadratic cost of brute-force height calculation"
|
||||||
|
|
||||||
|
time_complexity: "O(n). Each node and edge is processed exactly once during the leaf trimming process."
|
||||||
|
space_complexity: "O(n). We store the adjacency list and degree array, each with at most `n` entries."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Topological Sort (Leaf Trimming)
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
def find_min_height_trees(n: int, edges: list[list[int]]) -> list[int]:
|
||||||
|
# Edge cases: single node or two nodes
|
||||||
|
if n == 1:
|
||||||
|
return [0]
|
||||||
|
if n == 2:
|
||||||
|
return [0, 1]
|
||||||
|
|
||||||
|
# Build adjacency list using sets for O(1) removal
|
||||||
|
adj = [set() for _ in range(n)]
|
||||||
|
for a, b in edges:
|
||||||
|
adj[a].add(b)
|
||||||
|
adj[b].add(a)
|
||||||
|
|
||||||
|
# Find initial leaves (nodes with only one connection)
|
||||||
|
leaves = deque()
|
||||||
|
for i in range(n):
|
||||||
|
if len(adj[i]) == 1:
|
||||||
|
leaves.append(i)
|
||||||
|
|
||||||
|
# Trim leaves until 1 or 2 nodes remain
|
||||||
|
remaining = n
|
||||||
|
while remaining > 2:
|
||||||
|
# Process all current leaves
|
||||||
|
leaf_count = len(leaves)
|
||||||
|
remaining -= leaf_count
|
||||||
|
|
||||||
|
for _ in range(leaf_count):
|
||||||
|
leaf = leaves.popleft()
|
||||||
|
# Get the single neighbour of this leaf
|
||||||
|
neighbour = adj[leaf].pop()
|
||||||
|
# Remove leaf from neighbour's adjacency
|
||||||
|
adj[neighbour].remove(leaf)
|
||||||
|
|
||||||
|
# If neighbour is now a leaf, add to next round
|
||||||
|
if len(adj[neighbour]) == 1:
|
||||||
|
leaves.append(neighbour)
|
||||||
|
|
||||||
|
# Remaining nodes are the MHT roots
|
||||||
|
return list(leaves)
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — We process each node once when it becomes a leaf and each edge once when removing connections.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n) — The adjacency list stores each edge twice (bidirectional), giving O(n) edges for a tree with n nodes.
|
||||||
|
|
||||||
|
The algorithm peels away layers of leaves like an onion. After each round, nodes that were previously internal may become leaves. When only 1 or 2 nodes remain, they're the MHT roots — the structural centre of the tree.
|
||||||
|
|
||||||
|
- approach_name: BFS from Every Node (Brute Force)
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
def find_min_height_trees(n: int, edges: list[list[int]]) -> list[int]:
|
||||||
|
if n == 1:
|
||||||
|
return [0]
|
||||||
|
|
||||||
|
# Build adjacency list
|
||||||
|
adj = [[] for _ in range(n)]
|
||||||
|
for a, b in edges:
|
||||||
|
adj[a].append(b)
|
||||||
|
adj[b].append(a)
|
||||||
|
|
||||||
|
def get_height(root: int) -> int:
|
||||||
|
"""BFS to find tree height when rooted at given node."""
|
||||||
|
visited = [False] * n
|
||||||
|
queue = deque([(root, 0)])
|
||||||
|
visited[root] = True
|
||||||
|
max_depth = 0
|
||||||
|
|
||||||
|
while queue:
|
||||||
|
node, depth = queue.popleft()
|
||||||
|
max_depth = max(max_depth, depth)
|
||||||
|
for neighbour in adj[node]:
|
||||||
|
if not visited[neighbour]:
|
||||||
|
visited[neighbour] = True
|
||||||
|
queue.append((neighbour, depth + 1))
|
||||||
|
|
||||||
|
return max_depth
|
||||||
|
|
||||||
|
# Try every node as root and track minimum height
|
||||||
|
min_height = float('inf')
|
||||||
|
heights = []
|
||||||
|
|
||||||
|
for node in range(n):
|
||||||
|
h = get_height(node)
|
||||||
|
heights.append(h)
|
||||||
|
min_height = min(min_height, h)
|
||||||
|
|
||||||
|
# Return all nodes that achieve minimum height
|
||||||
|
return [i for i, h in enumerate(heights) if h == min_height]
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n²) — For each of n nodes, we run BFS which takes O(n) time.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n) — Adjacency list and visited array.
|
||||||
|
|
||||||
|
This brute force approach tries every node as a potential root and computes the tree height using BFS. While correct, it's too slow for large inputs. Included to illustrate why the leaf-trimming approach is necessary.
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
title: Minimum Interval to Include Each Query
|
||||||
|
slug: minimum-interval-to-include-each-query
|
||||||
|
difficulty: hard
|
||||||
|
leetcode_id: 1851
|
||||||
|
leetcode_url: https://leetcode.com/problems/minimum-interval-to-include-each-query/
|
||||||
|
categories:
|
||||||
|
- arrays
|
||||||
|
- sorting
|
||||||
|
- heap
|
||||||
|
patterns:
|
||||||
|
- heap
|
||||||
|
|
||||||
|
description: |
|
||||||
|
You are given a 2D integer array `intervals`, where `intervals[i] = [left_i, right_i]` describes the i<sup>th</sup> interval starting at `left_i` and ending at `right_i` **(inclusive)**. The **size** of an interval is defined as the number of integers it contains, or more formally `right_i - left_i + 1`.
|
||||||
|
|
||||||
|
You are also given an integer array `queries`. The answer to the j<sup>th</sup> query is the **size of the smallest interval** `i` such that `left_i <= queries[j] <= right_i`. If no such interval exists, the answer is `-1`.
|
||||||
|
|
||||||
|
Return *an array containing the answers to the queries*.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `1 <= intervals.length <= 10^5`
|
||||||
|
- `1 <= queries.length <= 10^5`
|
||||||
|
- `intervals[i].length == 2`
|
||||||
|
- `1 <= left_i <= right_i <= 10^7`
|
||||||
|
- `1 <= queries[j] <= 10^7`
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: "intervals = [[1,4],[2,4],[3,6],[4,4]], queries = [2,3,4,5]"
|
||||||
|
output: "[3,3,1,4]"
|
||||||
|
explanation: "Query = 2: The interval [2,4] is the smallest containing 2 (size = 3). Query = 3: [2,4] is smallest (size = 3). Query = 4: [4,4] is smallest (size = 1). Query = 5: [3,6] is smallest (size = 4)."
|
||||||
|
- input: "intervals = [[2,3],[2,5],[1,8],[20,25]], queries = [2,19,5,22]"
|
||||||
|
output: "[2,-1,4,6]"
|
||||||
|
explanation: "Query = 2: [2,3] is smallest (size = 2). Query = 19: No interval contains 19, answer is -1. Query = 5: [2,5] is smallest (size = 4). Query = 22: [20,25] is smallest (size = 6)."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Imagine you have a collection of intervals laid out on a number line, and for each query point, you need to find which interval "wraps" around it most tightly.
|
||||||
|
|
||||||
|
The brute force approach would check every interval for every query, resulting in O(n * m) complexity. With up to 10^5 intervals and 10^5 queries, this means 10^10 operations — far too slow.
|
||||||
|
|
||||||
|
The key insight is to **process queries in sorted order**. If we sort both intervals and queries by their starting positions, we can efficiently manage which intervals are "active" (could potentially contain the current query) using a **min-heap**.
|
||||||
|
|
||||||
|
Think of it like a sweep line moving left to right across the number line:
|
||||||
|
- As we reach each query point, we "activate" all intervals that start at or before this point
|
||||||
|
- We remove intervals whose right endpoint is before the query (they can't contain it)
|
||||||
|
- Among the remaining active intervals, the smallest one wins
|
||||||
|
|
||||||
|
The min-heap keeps intervals sorted by size, so after removing invalid ones, the top of the heap is our answer.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using a **Sorted Queries + Min-Heap** approach:
|
||||||
|
|
||||||
|
**Step 1: Prepare the data**
|
||||||
|
|
||||||
|
- Sort the intervals by their left endpoint (starting position)
|
||||||
|
- Create a list of `(query_value, original_index)` pairs and sort by query value
|
||||||
|
- This allows us to process queries left-to-right while preserving original order for the result
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Initialise tracking variables**
|
||||||
|
|
||||||
|
- `result`: Array of size `len(queries)` to store answers
|
||||||
|
- `min_heap`: Priority queue storing `(interval_size, right_endpoint)` tuples
|
||||||
|
- `interval_idx`: Pointer to track which intervals we've processed
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Process each query in sorted order**
|
||||||
|
|
||||||
|
For each query (from smallest to largest):
|
||||||
|
|
||||||
|
- **Add intervals**: While there are intervals with `left <= query`, push `(size, right)` onto the heap and advance `interval_idx`
|
||||||
|
- **Remove expired intervals**: While the heap is non-empty and the top interval's `right < query`, pop it (it can't contain this query)
|
||||||
|
- **Record answer**: If the heap is non-empty, the top element's size is our answer; otherwise, answer is `-1`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 4: Return the result**
|
||||||
|
|
||||||
|
- Since we processed queries in sorted order but stored answers at original indices, `result` is already correctly ordered
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
This approach works because sorting allows us to add intervals exactly once and remove them at most once, achieving optimal efficiency.
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: The Brute Force Trap
|
||||||
|
description: |
|
||||||
|
A naive approach checks every interval for every query:
|
||||||
|
|
||||||
|
```python
|
||||||
|
for query in queries:
|
||||||
|
for left, right in intervals:
|
||||||
|
if left <= query <= right:
|
||||||
|
# track minimum size
|
||||||
|
```
|
||||||
|
|
||||||
|
This is **O(n * m)** where n = number of intervals and m = number of queries. With constraints of 10^5 for both, this means 10^10 operations — guaranteed **Time Limit Exceeded**.
|
||||||
|
wrong_approach: "Nested loops checking all interval-query pairs"
|
||||||
|
correct_approach: "Sort and sweep with a min-heap for O((n + m) log n)"
|
||||||
|
|
||||||
|
- title: Forgetting to Preserve Query Order
|
||||||
|
description: |
|
||||||
|
Since we process queries in sorted order for efficiency, we must remember their original positions. If you just sort `queries` directly and build the result in that order, you'll return answers in the wrong order.
|
||||||
|
|
||||||
|
Always pair each query with its original index: `sorted_queries = sorted(enumerate(queries), key=lambda x: x[1])`, then use the index to place answers correctly.
|
||||||
|
wrong_approach: "Sort queries and return answers in sorted order"
|
||||||
|
correct_approach: "Track original indices and place answers accordingly"
|
||||||
|
|
||||||
|
- title: Not Removing Expired Intervals
|
||||||
|
description: |
|
||||||
|
After adding intervals to the heap, you must check if the top interval has expired (its `right < query`). Without this cleanup step, you might return the size of an interval that doesn't actually contain the query point.
|
||||||
|
|
||||||
|
The key is that the heap is sorted by size, not by validity. Always pop expired intervals before reading the answer.
|
||||||
|
wrong_approach: "Assume all intervals in heap are valid"
|
||||||
|
correct_approach: "Pop intervals where right < query before reading answer"
|
||||||
|
|
||||||
|
- title: Wrong Heap Priority
|
||||||
|
description: |
|
||||||
|
The heap should be ordered by interval **size** (smallest first), not by left or right endpoint. The problem asks for the smallest interval containing the query, so size must be the primary sort key.
|
||||||
|
|
||||||
|
Store tuples as `(size, right)` where `size = right - left + 1`.
|
||||||
|
wrong_approach: "Heap ordered by left endpoint or right endpoint"
|
||||||
|
correct_approach: "Heap ordered by interval size (right - left + 1)"
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Sweep line pattern**: Sorting queries enables efficient left-to-right processing where intervals are added once and removed at most once"
|
||||||
|
- "**Min-heap for tracking minimums**: When you need the minimum among a dynamic set of candidates, a min-heap provides O(log n) operations"
|
||||||
|
- "**Lazy deletion**: Instead of eagerly removing intervals, we check validity when we need the answer — a common optimisation with heaps"
|
||||||
|
- "**Preserve original order**: When processing data in sorted order for efficiency, track original indices to reconstruct the expected output order"
|
||||||
|
|
||||||
|
time_complexity: "O((n + m) log n). Sorting intervals takes O(n log n), sorting queries takes O(m log m). Each interval is pushed and popped from the heap at most once, giving O(n log n) heap operations. Total: O((n + m) log(n + m)), which simplifies to O((n + m) log n)."
|
||||||
|
space_complexity: "O(n + m). The heap can hold up to n intervals, and we store m query-index pairs. The result array uses O(m) space."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Sorted Queries with Min-Heap
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
import heapq
|
||||||
|
|
||||||
|
def min_interval(intervals: list[list[int]], queries: list[int]) -> list[int]:
|
||||||
|
# Sort intervals by left endpoint
|
||||||
|
intervals.sort(key=lambda x: x[0])
|
||||||
|
|
||||||
|
# Pair each query with its original index, then sort by query value
|
||||||
|
sorted_queries = sorted(enumerate(queries), key=lambda x: x[1])
|
||||||
|
|
||||||
|
result = [-1] * len(queries)
|
||||||
|
min_heap = [] # (interval_size, right_endpoint)
|
||||||
|
interval_idx = 0
|
||||||
|
|
||||||
|
for original_idx, query in sorted_queries:
|
||||||
|
# Add all intervals that start at or before this query
|
||||||
|
while interval_idx < len(intervals) and intervals[interval_idx][0] <= query:
|
||||||
|
left, right = intervals[interval_idx]
|
||||||
|
size = right - left + 1
|
||||||
|
heapq.heappush(min_heap, (size, right))
|
||||||
|
interval_idx += 1
|
||||||
|
|
||||||
|
# Remove intervals that end before this query (can't contain it)
|
||||||
|
while min_heap and min_heap[0][1] < query:
|
||||||
|
heapq.heappop(min_heap)
|
||||||
|
|
||||||
|
# If any valid intervals remain, the smallest is at the top
|
||||||
|
if min_heap:
|
||||||
|
result[original_idx] = min_heap[0][0]
|
||||||
|
|
||||||
|
return result
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O((n + m) log n) — Sorting both arrays, plus each interval enters and leaves the heap at most once.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n + m) — Heap holds up to n intervals, result array holds m answers.
|
||||||
|
|
||||||
|
By sorting queries and processing them left-to-right, we ensure each interval is considered exactly once. The min-heap maintains candidate intervals sorted by size, and lazy deletion removes expired intervals only when needed.
|
||||||
|
|
||||||
|
- approach_name: Brute Force
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def min_interval(intervals: list[list[int]], queries: list[int]) -> list[int]:
|
||||||
|
result = []
|
||||||
|
|
||||||
|
for query in queries:
|
||||||
|
min_size = float('inf')
|
||||||
|
|
||||||
|
# Check every interval for this query
|
||||||
|
for left, right in intervals:
|
||||||
|
# Does this interval contain the query?
|
||||||
|
if left <= query <= right:
|
||||||
|
size = right - left + 1
|
||||||
|
min_size = min(min_size, size)
|
||||||
|
|
||||||
|
# -1 if no interval contains this query
|
||||||
|
result.append(min_size if min_size != float('inf') else -1)
|
||||||
|
|
||||||
|
return result
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n * m) — For each of m queries, we check all n intervals.
|
||||||
|
|
||||||
|
**Space Complexity:** O(m) — Only the result array.
|
||||||
|
|
||||||
|
This straightforward approach checks every interval for every query. While correct, it's far too slow for the given constraints (up to 10^10 operations). Included to illustrate why the optimised heap-based approach is necessary.
|
||||||
211
backend/data/questions/minimum-path-sum.yaml
Normal file
211
backend/data/questions/minimum-path-sum.yaml
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
title: Minimum Path Sum
|
||||||
|
slug: minimum-path-sum
|
||||||
|
difficulty: medium
|
||||||
|
leetcode_id: 64
|
||||||
|
leetcode_url: https://leetcode.com/problems/minimum-path-sum/
|
||||||
|
categories:
|
||||||
|
- arrays
|
||||||
|
- dynamic-programming
|
||||||
|
patterns:
|
||||||
|
- dynamic-programming
|
||||||
|
- matrix-traversal
|
||||||
|
|
||||||
|
description: |
|
||||||
|
Given a `m x n` `grid` filled with non-negative numbers, find a path from top left to bottom right, which minimizes the sum of all numbers along its path.
|
||||||
|
|
||||||
|
**Note:** You can only move either down or right at any point in time.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `m == grid.length`
|
||||||
|
- `n == grid[i].length`
|
||||||
|
- `1 <= m, n <= 200`
|
||||||
|
- `0 <= grid[i][j] <= 200`
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: "grid = [[1,3,1],[1,5,1],[4,2,1]]"
|
||||||
|
output: "7"
|
||||||
|
explanation: "Because the path 1 → 3 → 1 → 1 → 1 minimizes the sum."
|
||||||
|
- input: "grid = [[1,2,3],[4,5,6]]"
|
||||||
|
output: "12"
|
||||||
|
explanation: "The path 1 → 2 → 3 → 6 gives the minimum sum of 12."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Imagine you're navigating a grid where each cell has a cost, and you want to find the cheapest route from the top-left corner to the bottom-right corner.
|
||||||
|
|
||||||
|
The key insight is that to reach any cell `(i, j)`, you can only arrive from either the cell directly above `(i-1, j)` or the cell directly to the left `(i, j-1)`. This is because you can only move **down** or **right**.
|
||||||
|
|
||||||
|
Think of it like this: if you know the minimum cost to reach every cell above and to the left of your current position, then the minimum cost to reach your current cell is simply the current cell's value plus the **smaller** of the two incoming paths.
|
||||||
|
|
||||||
|
This is the essence of **dynamic programming** — we build up the solution by solving smaller subproblems (finding minimum paths to earlier cells) and combining them to solve larger ones.
|
||||||
|
|
||||||
|
The first row and first column are special cases: cells in the first row can only be reached from the left, and cells in the first column can only be reached from above.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using a **Dynamic Programming** approach:
|
||||||
|
|
||||||
|
**Step 1: Create a DP table**
|
||||||
|
|
||||||
|
- Create a 2D array `dp` of the same dimensions as `grid`
|
||||||
|
- `dp[i][j]` will store the minimum path sum to reach cell `(i, j)`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Initialise the starting point**
|
||||||
|
|
||||||
|
- `dp[0][0] = grid[0][0]`: The cost to reach the starting cell is just its own value
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Fill the first row**
|
||||||
|
|
||||||
|
- For each cell in the first row, you can only come from the left
|
||||||
|
- `dp[0][j] = dp[0][j-1] + grid[0][j]`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 4: Fill the first column**
|
||||||
|
|
||||||
|
- For each cell in the first column, you can only come from above
|
||||||
|
- `dp[i][0] = dp[i-1][0] + grid[i][0]`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 5: Fill the rest of the table**
|
||||||
|
|
||||||
|
- For each remaining cell `(i, j)`, take the minimum of coming from above or from the left
|
||||||
|
- `dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i][j]`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 6: Return the result**
|
||||||
|
|
||||||
|
- Return `dp[m-1][n-1]`: The minimum path sum to reach the bottom-right corner
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Forgetting Boundary Conditions
|
||||||
|
description: |
|
||||||
|
A common mistake is trying to apply the general recurrence `min(dp[i-1][j], dp[i][j-1])` to all cells, including the first row and column.
|
||||||
|
|
||||||
|
For cells in the first row, there's no cell above (`i-1` would be `-1`). Similarly, for cells in the first column, there's no cell to the left. These boundary cases must be handled separately.
|
||||||
|
wrong_approach: "Applying the same formula to all cells without boundary checks"
|
||||||
|
correct_approach: "Handle first row and first column separately before the main loop"
|
||||||
|
|
||||||
|
- title: Using BFS/DFS Without Memoisation
|
||||||
|
description: |
|
||||||
|
You might think to use BFS or DFS to explore all paths. While this works, without memoisation you'll recompute the same subproblems many times.
|
||||||
|
|
||||||
|
For a `200 x 200` grid, the number of unique paths is astronomically large (`C(398, 199)`), and naive exploration will result in **Time Limit Exceeded (TLE)**.
|
||||||
|
|
||||||
|
Dynamic programming ensures each cell is computed exactly once, giving O(m × n) time complexity.
|
||||||
|
wrong_approach: "Recursive DFS exploring all paths without caching"
|
||||||
|
correct_approach: "Bottom-up DP filling the table systematically"
|
||||||
|
|
||||||
|
- title: Not Considering Space Optimisation
|
||||||
|
description: |
|
||||||
|
While a full `m x n` DP table works, you only need the previous row to compute the current row. This allows reducing space from O(m × n) to O(n).
|
||||||
|
|
||||||
|
This optimisation is optional but demonstrates deeper understanding of the DP pattern.
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**2D DP pattern**: When a problem involves a grid with constrained movement, consider building a DP table where each cell depends on previously computed cells"
|
||||||
|
- "**Optimal substructure**: The minimum path to any cell is determined by the minimum paths to its predecessors — a hallmark of DP problems"
|
||||||
|
- "**Space optimisation**: Since each row only depends on the previous row, you can reduce space from O(m × n) to O(n) by reusing a single row array"
|
||||||
|
- "**Related problems**: This pattern extends to Unique Paths, Unique Paths II, Triangle, and other grid-based DP problems"
|
||||||
|
|
||||||
|
time_complexity: "O(m × n). We visit each cell in the grid exactly once to compute its minimum path sum."
|
||||||
|
space_complexity: "O(m × n) for the standard solution using a full DP table. Can be optimised to O(n) by reusing a single row, or O(1) by modifying the input grid in place."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Dynamic Programming (2D Table)
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def min_path_sum(grid: list[list[int]]) -> int:
|
||||||
|
m, n = len(grid), len(grid[0])
|
||||||
|
|
||||||
|
# Create DP table to store minimum path sums
|
||||||
|
dp = [[0] * n for _ in range(m)]
|
||||||
|
|
||||||
|
# Starting point - cost is just the cell value
|
||||||
|
dp[0][0] = grid[0][0]
|
||||||
|
|
||||||
|
# Fill first row - can only come from the left
|
||||||
|
for j in range(1, n):
|
||||||
|
dp[0][j] = dp[0][j - 1] + grid[0][j]
|
||||||
|
|
||||||
|
# Fill first column - can only come from above
|
||||||
|
for i in range(1, m):
|
||||||
|
dp[i][0] = dp[i - 1][0] + grid[i][0]
|
||||||
|
|
||||||
|
# Fill the rest - take minimum of above or left
|
||||||
|
for i in range(1, m):
|
||||||
|
for j in range(1, n):
|
||||||
|
dp[i][j] = min(dp[i - 1][j], dp[i][j - 1]) + grid[i][j]
|
||||||
|
|
||||||
|
# Return minimum path sum to bottom-right corner
|
||||||
|
return dp[m - 1][n - 1]
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(m × n) — We iterate through each cell once.
|
||||||
|
|
||||||
|
**Space Complexity:** O(m × n) — We use a separate DP table of the same size as the grid.
|
||||||
|
|
||||||
|
This approach builds up the solution systematically. Each cell stores the minimum cost to reach it, and we use previously computed values to find the answer efficiently.
|
||||||
|
|
||||||
|
- approach_name: Space-Optimised DP (Single Row)
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def min_path_sum(grid: list[list[int]]) -> int:
|
||||||
|
m, n = len(grid), len(grid[0])
|
||||||
|
|
||||||
|
# Use single row - reuse for each row of the grid
|
||||||
|
dp = [0] * n
|
||||||
|
|
||||||
|
for i in range(m):
|
||||||
|
for j in range(n):
|
||||||
|
if i == 0 and j == 0:
|
||||||
|
# Starting point
|
||||||
|
dp[j] = grid[0][0]
|
||||||
|
elif i == 0:
|
||||||
|
# First row - can only come from left
|
||||||
|
dp[j] = dp[j - 1] + grid[i][j]
|
||||||
|
elif j == 0:
|
||||||
|
# First column - can only come from above
|
||||||
|
dp[j] = dp[j] + grid[i][j]
|
||||||
|
else:
|
||||||
|
# General case - min of above (dp[j]) or left (dp[j-1])
|
||||||
|
dp[j] = min(dp[j], dp[j - 1]) + grid[i][j]
|
||||||
|
|
||||||
|
return dp[n - 1]
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(m × n) — Same as the 2D approach.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n) — We only store one row at a time.
|
||||||
|
|
||||||
|
This optimisation works because when computing row `i`, we only need values from row `i-1`. The value `dp[j]` before update represents the cell above, and `dp[j-1]` after update represents the cell to the left.
|
||||||
|
|
||||||
|
- approach_name: In-Place Modification
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def min_path_sum(grid: list[list[int]]) -> int:
|
||||||
|
m, n = len(grid), len(grid[0])
|
||||||
|
|
||||||
|
# Modify grid in place - fill first row
|
||||||
|
for j in range(1, n):
|
||||||
|
grid[0][j] += grid[0][j - 1]
|
||||||
|
|
||||||
|
# Fill first column
|
||||||
|
for i in range(1, m):
|
||||||
|
grid[i][0] += grid[i - 1][0]
|
||||||
|
|
||||||
|
# Fill rest of grid
|
||||||
|
for i in range(1, m):
|
||||||
|
for j in range(1, n):
|
||||||
|
grid[i][j] += min(grid[i - 1][j], grid[i][j - 1])
|
||||||
|
|
||||||
|
return grid[m - 1][n - 1]
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(m × n) — Same traversal pattern.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — No extra space used, but modifies input.
|
||||||
|
|
||||||
|
This approach achieves O(1) space by using the input grid itself as the DP table. However, it **mutates the input**, which may not be acceptable in all contexts. Use this only when input modification is explicitly allowed.
|
||||||
215
backend/data/questions/minimum-size-subarray-sum.yaml
Normal file
215
backend/data/questions/minimum-size-subarray-sum.yaml
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
title: Minimum Size Subarray Sum
|
||||||
|
slug: minimum-size-subarray-sum
|
||||||
|
difficulty: medium
|
||||||
|
leetcode_id: 209
|
||||||
|
leetcode_url: https://leetcode.com/problems/minimum-size-subarray-sum/
|
||||||
|
categories:
|
||||||
|
- arrays
|
||||||
|
- binary-search
|
||||||
|
patterns:
|
||||||
|
- sliding-window
|
||||||
|
- binary-search
|
||||||
|
- prefix-sum
|
||||||
|
|
||||||
|
description: |
|
||||||
|
Given an array of positive integers `nums` and a positive integer `target`, return *the **minimal length** of a subarray whose sum is greater than or equal to* `target`. If there is no such subarray, return `0` instead.
|
||||||
|
|
||||||
|
A **subarray** is a contiguous non-empty sequence of elements within an array.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `1 <= target <= 10^9`
|
||||||
|
- `1 <= nums.length <= 10^5`
|
||||||
|
- `1 <= nums[i] <= 10^4`
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: "target = 7, nums = [2,3,1,2,4,3]"
|
||||||
|
output: "2"
|
||||||
|
explanation: "The subarray [4,3] has the minimal length under the problem constraint."
|
||||||
|
- input: "target = 4, nums = [1,4,4]"
|
||||||
|
output: "1"
|
||||||
|
explanation: "The subarray [4] satisfies the condition with length 1."
|
||||||
|
- input: "target = 11, nums = [1,1,1,1,1,1,1,1]"
|
||||||
|
output: "0"
|
||||||
|
explanation: "No subarray sums to 11 or greater (total sum is only 8)."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Imagine you have a **stretchy window** that you slide across the array. The window has two ends: a left boundary and a right boundary. Your goal is to find the smallest window whose elements sum to at least `target`.
|
||||||
|
|
||||||
|
Here's the key insight: since all numbers are **positive**, adding more elements to the window can only **increase** the sum, and removing elements can only **decrease** it. This monotonic property is what makes the sliding window technique work.
|
||||||
|
|
||||||
|
Think of it like this: you start by expanding the right end of the window, adding elements until the sum reaches the target. Once it does, you try to **shrink** the window from the left to see if you can achieve the same sum with fewer elements. You keep track of the smallest valid window you find.
|
||||||
|
|
||||||
|
This "expand then contract" pattern is the essence of the **sliding window** technique for finding minimum-length subarrays.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using the **Sliding Window** technique:
|
||||||
|
|
||||||
|
**Step 1: Initialise variables**
|
||||||
|
|
||||||
|
- `left = 0`: Left boundary of our window
|
||||||
|
- `current_sum = 0`: Running sum of elements in the window
|
||||||
|
- `min_length = infinity`: Track the smallest valid window (use infinity so any valid window becomes the minimum)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Expand the window by moving the right pointer**
|
||||||
|
|
||||||
|
- Iterate `right` from `0` to `n - 1`
|
||||||
|
- Add `nums[right]` to `current_sum` — this expands the window
|
||||||
|
- After each expansion, check if the sum meets or exceeds `target`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Contract the window while the sum is valid**
|
||||||
|
|
||||||
|
- While `current_sum >= target`:
|
||||||
|
- Update `min_length` with the current window size: `right - left + 1`
|
||||||
|
- Remove `nums[left]` from `current_sum` — this shrinks the window
|
||||||
|
- Move `left` forward: `left += 1`
|
||||||
|
- This inner loop finds the minimum window ending at the current `right` position
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 4: Return the result**
|
||||||
|
|
||||||
|
- If `min_length` is still infinity, no valid subarray exists — return `0`
|
||||||
|
- Otherwise, return `min_length`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
The sliding window works because all elements are positive: once the sum drops below `target`, we need to expand right again (no point shrinking further).
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Using a Brute Force Nested Loop
|
||||||
|
description: |
|
||||||
|
A naive approach tries every possible subarray:
|
||||||
|
- Outer loop for start index
|
||||||
|
- Inner loop for end index
|
||||||
|
- Sum calculation for each subarray
|
||||||
|
|
||||||
|
This results in **O(n^2)** or **O(n^3)** time complexity. With `nums.length <= 10^5`, this will cause **Time Limit Exceeded (TLE)**.
|
||||||
|
|
||||||
|
The sliding window achieves O(n) because each element is added and removed at most once.
|
||||||
|
wrong_approach: "Nested loops for all subarrays"
|
||||||
|
correct_approach: "Sliding window with two pointers"
|
||||||
|
|
||||||
|
- title: Forgetting Elements Are Positive
|
||||||
|
description: |
|
||||||
|
The sliding window technique **only works here because all elements are positive**. This guarantees:
|
||||||
|
- Expanding the window always increases the sum
|
||||||
|
- Shrinking the window always decreases the sum
|
||||||
|
|
||||||
|
If negative numbers were allowed, you couldn't shrink the window confidently — removing a negative number would increase the sum!
|
||||||
|
|
||||||
|
For arrays with negatives, you'd need a different approach (like prefix sums with binary search or monotonic deque).
|
||||||
|
wrong_approach: "Applying sliding window blindly to any subarray sum problem"
|
||||||
|
correct_approach: "Verify elements are positive before using sliding window"
|
||||||
|
|
||||||
|
- title: Returning min_length Without Checking Validity
|
||||||
|
description: |
|
||||||
|
If no subarray sums to `target` or more, `min_length` remains infinity. Returning this would be incorrect.
|
||||||
|
|
||||||
|
Always check: `return 0 if min_length == infinity else min_length`
|
||||||
|
|
||||||
|
Example: `target = 11, nums = [1,1,1,1,1,1,1,1]` — total sum is 8, so no valid subarray exists.
|
||||||
|
wrong_approach: "return min_length"
|
||||||
|
correct_approach: "return 0 if min_length == float('inf') else min_length"
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Sliding Window** is the go-to technique for contiguous subarray problems with monotonic conditions"
|
||||||
|
- "**Positive elements enable shrinking**: You can confidently shrink the window because removing elements always decreases the sum"
|
||||||
|
- "**Two-pointer pattern**: The left pointer never moves backward — each element is processed at most twice (once added, once removed)"
|
||||||
|
- "**O(n) despite nested loops**: The inner while loop is amortized O(1) because `left` moves at most `n` times total"
|
||||||
|
|
||||||
|
time_complexity: "O(n). Each element is added to the window once (when `right` passes it) and removed at most once (when `left` passes it). The inner while loop executes at most `n` times total across all iterations."
|
||||||
|
space_complexity: "O(1). We only use a fixed number of variables (`left`, `current_sum`, `min_length`) regardless of input size."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Sliding Window
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def min_subarray_len(target: int, nums: list[int]) -> int:
|
||||||
|
n = len(nums)
|
||||||
|
left = 0
|
||||||
|
current_sum = 0
|
||||||
|
min_length = float('inf') # Use infinity so any valid window becomes min
|
||||||
|
|
||||||
|
# Expand window by moving right pointer
|
||||||
|
for right in range(n):
|
||||||
|
current_sum += nums[right] # Add element to window
|
||||||
|
|
||||||
|
# Contract window while sum is valid
|
||||||
|
while current_sum >= target:
|
||||||
|
# Update minimum length
|
||||||
|
min_length = min(min_length, right - left + 1)
|
||||||
|
# Remove leftmost element and shrink window
|
||||||
|
current_sum -= nums[left]
|
||||||
|
left += 1
|
||||||
|
|
||||||
|
# Return 0 if no valid subarray found
|
||||||
|
return 0 if min_length == float('inf') else min_length
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — Each element is added and removed from the window at most once.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — Only a few variables needed.
|
||||||
|
|
||||||
|
The sliding window expands by moving `right` and contracts by moving `left`. Because all elements are positive, once the sum drops below `target`, we must expand again. This guarantees optimal efficiency.
|
||||||
|
|
||||||
|
- approach_name: Binary Search with Prefix Sums
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
import bisect
|
||||||
|
|
||||||
|
def min_subarray_len(target: int, nums: list[int]) -> int:
|
||||||
|
n = len(nums)
|
||||||
|
min_length = float('inf')
|
||||||
|
|
||||||
|
# Build prefix sum array: prefix[i] = sum of nums[0..i-1]
|
||||||
|
prefix = [0] * (n + 1)
|
||||||
|
for i in range(n):
|
||||||
|
prefix[i + 1] = prefix[i] + nums[i]
|
||||||
|
|
||||||
|
# For each starting position, binary search for the ending position
|
||||||
|
for i in range(n):
|
||||||
|
# We need prefix[j] - prefix[i] >= target
|
||||||
|
# So prefix[j] >= prefix[i] + target
|
||||||
|
needed = prefix[i] + target
|
||||||
|
# Find smallest j where prefix[j] >= needed
|
||||||
|
j = bisect.bisect_left(prefix, needed)
|
||||||
|
|
||||||
|
if j <= n: # Valid ending position found
|
||||||
|
min_length = min(min_length, j - i)
|
||||||
|
|
||||||
|
return 0 if min_length == float('inf') else min_length
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n log n) — Building prefix sums is O(n), and we do n binary searches of O(log n) each.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n) — For the prefix sum array.
|
||||||
|
|
||||||
|
This approach builds a prefix sum array, then for each starting index, binary searches for the smallest ending index that achieves the target sum. Works because prefix sums are monotonically increasing (all elements positive).
|
||||||
|
|
||||||
|
- approach_name: Brute Force
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def min_subarray_len(target: int, nums: list[int]) -> int:
|
||||||
|
n = len(nums)
|
||||||
|
min_length = float('inf')
|
||||||
|
|
||||||
|
# Try every starting position
|
||||||
|
for i in range(n):
|
||||||
|
current_sum = 0
|
||||||
|
# Extend to every ending position
|
||||||
|
for j in range(i, n):
|
||||||
|
current_sum += nums[j]
|
||||||
|
if current_sum >= target:
|
||||||
|
min_length = min(min_length, j - i + 1)
|
||||||
|
break # Found minimum for this start, move on
|
||||||
|
|
||||||
|
return 0 if min_length == float('inf') else min_length
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n^2) — Nested loops over all starting and ending positions.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — Only tracking current sum and minimum.
|
||||||
|
|
||||||
|
This checks every possible subarray starting position, extending until the sum meets the target. The `break` optimises slightly (we stop once we hit target), but worst case is still O(n^2). Too slow for large inputs.
|
||||||
271
backend/data/questions/minimum-window-substring.yaml
Normal file
271
backend/data/questions/minimum-window-substring.yaml
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
title: Minimum Window Substring
|
||||||
|
slug: minimum-window-substring
|
||||||
|
difficulty: hard
|
||||||
|
leetcode_id: 76
|
||||||
|
leetcode_url: https://leetcode.com/problems/minimum-window-substring/
|
||||||
|
categories:
|
||||||
|
- strings
|
||||||
|
- hash-tables
|
||||||
|
patterns:
|
||||||
|
- sliding-window
|
||||||
|
|
||||||
|
description: |
|
||||||
|
Given two strings `s` and `t` of lengths `m` and `n` respectively, return *the **minimum window substring*** of `s` such that every character in `t` (**including duplicates**) is included in the window.
|
||||||
|
|
||||||
|
If there is no such substring, return the empty string `""`.
|
||||||
|
|
||||||
|
The testcases will be generated such that the answer is **unique**.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `m == s.length`
|
||||||
|
- `n == t.length`
|
||||||
|
- `1 <= m, n <= 10^5`
|
||||||
|
- `s` and `t` consist of uppercase and lowercase English letters
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: 's = "ADOBECODEBANC", t = "ABC"'
|
||||||
|
output: '"BANC"'
|
||||||
|
explanation: "The minimum window substring \"BANC\" includes 'A', 'B', and 'C' from string t."
|
||||||
|
- input: 's = "a", t = "a"'
|
||||||
|
output: '"a"'
|
||||||
|
explanation: "The entire string s is the minimum window."
|
||||||
|
- input: 's = "a", t = "aa"'
|
||||||
|
output: '""'
|
||||||
|
explanation: "Both 'a's from t must be included in the window. Since the largest window of s only has one 'a', return empty string."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Imagine you're looking through a telescope at a long banner of letters (string `s`), and you need to find the smallest section that contains all the letters from your shopping list (string `t`).
|
||||||
|
|
||||||
|
The key insight is that we don't need to check every possible substring — that would be far too slow. Instead, we can use a **sliding window** technique: we expand a window to the right until it contains all required characters, then shrink it from the left to find the minimum valid window.
|
||||||
|
|
||||||
|
Think of it like this: you're stretching a rubber band around the letters. First, you stretch it wide enough to capture everything you need. Then, you carefully release from the left side, making it as small as possible while still holding all required letters.
|
||||||
|
|
||||||
|
The critical realisation is that we need to track **character frequencies**, not just presence. If `t = "aa"`, we need at least two `'a'`s in our window. We use two hash maps: one to count what we need, and one to count what we have in our current window.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using the **Sliding Window with Two Pointers** technique:
|
||||||
|
|
||||||
|
**Step 1: Build a frequency map for target string `t`**
|
||||||
|
|
||||||
|
- Create a hash map `need` counting each character's frequency in `t`
|
||||||
|
- Track `required`: the number of unique characters we must match (size of `need`)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Initialise window tracking variables**
|
||||||
|
|
||||||
|
- `left`, `right`: Window boundaries, both start at `0`
|
||||||
|
- `have`: Count of unique characters that meet the required frequency (starts at `0`)
|
||||||
|
- `window`: Hash map tracking character counts in our current window
|
||||||
|
- `result`: Tuple of `(window_length, left_index, right_index)` for the answer
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Expand the window by moving `right`**
|
||||||
|
|
||||||
|
- Add `s[right]` to the `window` map
|
||||||
|
- If this character is in `need` and its count now matches the required count, increment `have`
|
||||||
|
- Continue expanding until `have == required` (window contains all needed characters)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 4: Contract the window by moving `left`**
|
||||||
|
|
||||||
|
- While the window is valid (`have == required`):
|
||||||
|
- Update `result` if this window is smaller than our best
|
||||||
|
- Remove `s[left]` from the window map
|
||||||
|
- If this causes a needed character to drop below its required count, decrement `have`
|
||||||
|
- Move `left` forward
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 5: Return the result**
|
||||||
|
|
||||||
|
- If we never found a valid window, return `""`
|
||||||
|
- Otherwise, return the substring from `result`
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: The Brute Force Trap
|
||||||
|
description: |
|
||||||
|
A naive approach checks every possible substring of `s` to see if it contains all characters of `t`. With `m` substrings of varying lengths and `O(m + n)` to validate each, this results in **O(m^2 * n)** time complexity.
|
||||||
|
|
||||||
|
For `m = n = 10^5`, this means up to 10^15 operations — guaranteed **Time Limit Exceeded**.
|
||||||
|
wrong_approach: "Nested loops checking all substrings"
|
||||||
|
correct_approach: "Sliding window expanding and contracting in O(m + n)"
|
||||||
|
|
||||||
|
- title: Checking Only Character Presence
|
||||||
|
description: |
|
||||||
|
It's tempting to just check if all characters from `t` exist in the window. But `t` can have **duplicate characters**.
|
||||||
|
|
||||||
|
For `t = "aa"`, a window containing just one `'a'` is invalid. You must track **frequencies**, not just presence.
|
||||||
|
wrong_approach: "Using a set to check character existence"
|
||||||
|
correct_approach: "Using hash maps to track character counts"
|
||||||
|
|
||||||
|
- title: Forgetting to Shrink the Window
|
||||||
|
description: |
|
||||||
|
Some solutions only expand the window when a character is missing, but forget to shrink it after finding a valid window.
|
||||||
|
|
||||||
|
The key optimisation is that after finding any valid window, we must try to shrink it from the left. The first valid window is rarely the minimum — you need to keep contracting.
|
||||||
|
wrong_approach: "Only expanding, never contracting"
|
||||||
|
correct_approach: "Contract from left whenever window is valid"
|
||||||
|
|
||||||
|
- title: Off-by-One Errors in Substring Extraction
|
||||||
|
description: |
|
||||||
|
When tracking window boundaries and extracting the final substring, it's easy to be off by one.
|
||||||
|
|
||||||
|
Remember: `s[left:right+1]` includes both endpoints if using zero-indexed positions where `right` points to the last character in the window.
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Sliding Window Pattern**: When finding minimum/maximum substrings satisfying a condition, think expand-then-contract"
|
||||||
|
- "**Two Hash Maps**: One for 'what we need', one for 'what we have' — a common technique for substring problems with character requirements"
|
||||||
|
- "**Track Satisfied Conditions**: Instead of re-validating the entire window, track how many conditions are met and update incrementally"
|
||||||
|
- "**Foundation for Similar Problems**: This technique applies to problems like Longest Substring Without Repeating Characters, Permutation in String, and Find All Anagrams"
|
||||||
|
|
||||||
|
time_complexity: "O(m + n). We visit each character in `s` at most twice (once when expanding `right`, once when contracting `left`), and we do O(n) work to build the frequency map for `t`."
|
||||||
|
space_complexity: "O(m + n). In the worst case, our hash maps store all unique characters from both strings. With only English letters, this is bounded by O(52) = O(1), but the general case is O(m + n)."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Sliding Window with Hash Maps
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
def min_window(s: str, t: str) -> str:
|
||||||
|
if not t or not s:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Count characters we need from t
|
||||||
|
need = Counter(t)
|
||||||
|
required = len(need) # Number of unique chars to match
|
||||||
|
|
||||||
|
# Window tracking
|
||||||
|
window = {}
|
||||||
|
have = 0 # How many unique chars meet required frequency
|
||||||
|
|
||||||
|
# Result: (window_length, left, right)
|
||||||
|
result = (float("inf"), 0, 0)
|
||||||
|
|
||||||
|
left = 0
|
||||||
|
for right in range(len(s)):
|
||||||
|
# Expand: add character at right to window
|
||||||
|
char = s[right]
|
||||||
|
window[char] = window.get(char, 0) + 1
|
||||||
|
|
||||||
|
# Check if this char's frequency now matches what we need
|
||||||
|
if char in need and window[char] == need[char]:
|
||||||
|
have += 1
|
||||||
|
|
||||||
|
# Contract: shrink window while it's valid
|
||||||
|
while have == required:
|
||||||
|
# Update result if this window is smaller
|
||||||
|
window_len = right - left + 1
|
||||||
|
if window_len < result[0]:
|
||||||
|
result = (window_len, left, right)
|
||||||
|
|
||||||
|
# Remove leftmost character
|
||||||
|
left_char = s[left]
|
||||||
|
window[left_char] -= 1
|
||||||
|
|
||||||
|
# If this breaks a requirement, decrement have
|
||||||
|
if left_char in need and window[left_char] < need[left_char]:
|
||||||
|
have -= 1
|
||||||
|
|
||||||
|
left += 1
|
||||||
|
|
||||||
|
# Return result
|
||||||
|
length, start, end = result
|
||||||
|
return s[start:end + 1] if length != float("inf") else ""
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(m + n) — Building the frequency map takes O(n), and the sliding window visits each character in `s` at most twice.
|
||||||
|
|
||||||
|
**Space Complexity:** O(m + n) — Hash maps for character frequencies. In practice O(52) for English letters only.
|
||||||
|
|
||||||
|
This solution uses the classic sliding window pattern: expand until valid, then contract to find the minimum. The key insight is tracking `have` vs `required` to know when the window is valid in O(1) time.
|
||||||
|
|
||||||
|
- approach_name: Optimised Sliding Window (Filtered Characters)
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
def min_window(s: str, t: str) -> str:
|
||||||
|
if not t or not s:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
need = Counter(t)
|
||||||
|
required = len(need)
|
||||||
|
|
||||||
|
# Filter s to only keep relevant characters with their indices
|
||||||
|
filtered = [(i, char) for i, char in enumerate(s) if char in need]
|
||||||
|
|
||||||
|
window = {}
|
||||||
|
have = 0
|
||||||
|
result = (float("inf"), 0, 0)
|
||||||
|
|
||||||
|
left = 0
|
||||||
|
for right in range(len(filtered)):
|
||||||
|
# Expand window
|
||||||
|
idx_right, char = filtered[right]
|
||||||
|
window[char] = window.get(char, 0) + 1
|
||||||
|
|
||||||
|
if window[char] == need[char]:
|
||||||
|
have += 1
|
||||||
|
|
||||||
|
# Contract window
|
||||||
|
while have == required:
|
||||||
|
idx_left = filtered[left][0]
|
||||||
|
window_len = idx_right - idx_left + 1
|
||||||
|
|
||||||
|
if window_len < result[0]:
|
||||||
|
result = (window_len, idx_left, idx_right)
|
||||||
|
|
||||||
|
left_char = filtered[left][1]
|
||||||
|
window[left_char] -= 1
|
||||||
|
|
||||||
|
if window[left_char] < need[left_char]:
|
||||||
|
have -= 1
|
||||||
|
|
||||||
|
left += 1
|
||||||
|
|
||||||
|
length, start, end = result
|
||||||
|
return s[start:end + 1] if length != float("inf") else ""
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(m + n) — Same asymptotic complexity, but faster in practice when `s` has many characters not in `t`.
|
||||||
|
|
||||||
|
**Space Complexity:** O(m + n) — Additional space for the filtered list.
|
||||||
|
|
||||||
|
This optimisation pre-filters `s` to only include characters that appear in `t`. When `s` is much longer than `t` and contains many irrelevant characters, this reduces the number of iterations significantly.
|
||||||
|
|
||||||
|
- approach_name: Brute Force
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
def min_window(s: str, t: str) -> str:
|
||||||
|
def contains_all(sub: str, target: str) -> bool:
|
||||||
|
"""Check if substring contains all characters from target."""
|
||||||
|
sub_count = Counter(sub)
|
||||||
|
target_count = Counter(target)
|
||||||
|
for char, count in target_count.items():
|
||||||
|
if sub_count.get(char, 0) < count:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
m, n = len(s), len(t)
|
||||||
|
if n > m:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Try all substrings, starting from smallest possible
|
||||||
|
for length in range(n, m + 1):
|
||||||
|
for start in range(m - length + 1):
|
||||||
|
substring = s[start:start + length]
|
||||||
|
if contains_all(substring, t):
|
||||||
|
return substring
|
||||||
|
|
||||||
|
return ""
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(m^2 * n) — We check O(m^2) substrings, each taking O(m + n) to validate.
|
||||||
|
|
||||||
|
**Space Complexity:** O(m + n) — Counter objects for each substring comparison.
|
||||||
|
|
||||||
|
This brute force approach tries all possible substrings in order of increasing length. While correct, it's far too slow for the given constraints and will result in TLE. Included to illustrate why the sliding window approach is necessary.
|
||||||
198
backend/data/questions/missing-number.yaml
Normal file
198
backend/data/questions/missing-number.yaml
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
title: Missing Number
|
||||||
|
slug: missing-number
|
||||||
|
difficulty: easy
|
||||||
|
leetcode_id: 268
|
||||||
|
leetcode_url: https://leetcode.com/problems/missing-number/
|
||||||
|
categories:
|
||||||
|
- arrays
|
||||||
|
- math
|
||||||
|
patterns:
|
||||||
|
- prefix-sum
|
||||||
|
|
||||||
|
function_signature: "def missing_number(nums: list[int]) -> int:"
|
||||||
|
|
||||||
|
test_cases:
|
||||||
|
visible:
|
||||||
|
- input: { nums: [3, 0, 1] }
|
||||||
|
expected: 2
|
||||||
|
- input: { nums: [0, 1] }
|
||||||
|
expected: 2
|
||||||
|
- input: { nums: [9, 6, 4, 2, 3, 5, 7, 0, 1] }
|
||||||
|
expected: 8
|
||||||
|
hidden:
|
||||||
|
- input: { nums: [0] }
|
||||||
|
expected: 1
|
||||||
|
- input: { nums: [1] }
|
||||||
|
expected: 0
|
||||||
|
- input: { nums: [0, 1, 2, 3, 5] }
|
||||||
|
expected: 4
|
||||||
|
|
||||||
|
description: |
|
||||||
|
Given an array `nums` containing `n` distinct numbers in the range `[0, n]`, return *the only number in the range that is missing from the array*.
|
||||||
|
|
||||||
|
**Example 1:**
|
||||||
|
|
||||||
|
**Input:** `nums = [3,0,1]`
|
||||||
|
|
||||||
|
**Output:** `2`
|
||||||
|
|
||||||
|
**Explanation:** `n = 3` since there are 3 numbers, so all numbers are in the range `[0,3]`. 2 is the missing number in the range since it does not appear in `nums`.
|
||||||
|
|
||||||
|
**Example 2:**
|
||||||
|
|
||||||
|
**Input:** `nums = [0,1]`
|
||||||
|
|
||||||
|
**Output:** `2`
|
||||||
|
|
||||||
|
**Explanation:** `n = 2` since there are 2 numbers, so all numbers are in the range `[0,2]`. 2 is the missing number in the range since it does not appear in `nums`.
|
||||||
|
|
||||||
|
**Example 3:**
|
||||||
|
|
||||||
|
**Input:** `nums = [9,6,4,2,3,5,7,0,1]`
|
||||||
|
|
||||||
|
**Output:** `8`
|
||||||
|
|
||||||
|
**Explanation:** `n = 9` since there are 9 numbers, so all numbers are in the range `[0,9]`. 8 is the missing number in the range since it does not appear in `nums`.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `n == nums.length`
|
||||||
|
- `1 <= n <= 10^4`
|
||||||
|
- `0 <= nums[i] <= n`
|
||||||
|
- All the numbers of `nums` are **unique**
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: "nums = [3,0,1]"
|
||||||
|
output: "2"
|
||||||
|
explanation: "n = 3 since there are 3 numbers, so all numbers are in the range [0,3]. 2 is the missing number."
|
||||||
|
- input: "nums = [0,1]"
|
||||||
|
output: "2"
|
||||||
|
explanation: "n = 2 since there are 2 numbers, so all numbers are in the range [0,2]. 2 is the missing number."
|
||||||
|
- input: "nums = [9,6,4,2,3,5,7,0,1]"
|
||||||
|
output: "8"
|
||||||
|
explanation: "n = 9 since there are 9 numbers, so all numbers are in the range [0,9]. 8 is the missing number."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Imagine you have a row of numbered lockers from `0` to `n`, and each locker should contain a ball with a matching number. Someone has stolen exactly one ball, and you need to figure out which one is missing.
|
||||||
|
|
||||||
|
The key insight is that we **know what the complete set should look like**. If no ball were missing, the sum of all numbers from `0` to `n` would follow a well-known mathematical formula: `n * (n + 1) / 2`. This is the sum of an arithmetic sequence.
|
||||||
|
|
||||||
|
Think of it like this: if you calculate the expected total and then subtract everything you actually have, whatever remains must be the missing piece. It's like knowing you should have $55 in your wallet (1+2+3+...+10), counting what's there and finding $47, and immediately knowing the missing bill is $8.
|
||||||
|
|
||||||
|
This approach transforms a search problem into a simple arithmetic problem, which is both elegant and efficient.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using the **Gauss Sum Formula**:
|
||||||
|
|
||||||
|
**Step 1: Calculate the expected sum**
|
||||||
|
|
||||||
|
- Use the formula `n * (n + 1) / 2` to compute what the sum would be if all numbers from `0` to `n` were present
|
||||||
|
- `n` is the length of the input array (since we have `n` numbers in range `[0, n]`, one is missing)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Calculate the actual sum**
|
||||||
|
|
||||||
|
- Sum all elements currently in the array
|
||||||
|
- This can be done with a simple loop or the built-in `sum()` function
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Find the difference**
|
||||||
|
|
||||||
|
- Subtract the actual sum from the expected sum
|
||||||
|
- The result is the missing number
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
This works because addition is commutative and associative — the order of elements doesn't matter. Whatever is "missing" from the actual sum compared to the expected sum must be our answer.
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Using a Hash Set
|
||||||
|
description: |
|
||||||
|
A common approach is to put all numbers in a hash set, then check each number from `0` to `n` to find which one is missing.
|
||||||
|
|
||||||
|
While this works and runs in O(n) time, it uses **O(n) extra space** for the set. The problem's follow-up asks for O(1) space, which the sum approach achieves.
|
||||||
|
wrong_approach: "Hash set to track seen numbers"
|
||||||
|
correct_approach: "Mathematical sum formula for O(1) space"
|
||||||
|
|
||||||
|
- title: Sorting First
|
||||||
|
description: |
|
||||||
|
Another instinct is to sort the array and then scan for a gap where `nums[i] != i`.
|
||||||
|
|
||||||
|
This works but takes **O(n log n) time** for the sort. The sum approach achieves O(n) time with O(1) space — strictly better on both dimensions.
|
||||||
|
wrong_approach: "Sort then scan for gaps"
|
||||||
|
correct_approach: "Sum formula for O(n) time"
|
||||||
|
|
||||||
|
- title: Integer Overflow Concerns
|
||||||
|
description: |
|
||||||
|
With `n` up to `10^4`, the expected sum could be around `10^4 * 10^4 / 2 = 5 * 10^7`. This fits comfortably in a 32-bit integer (max ~2.1 billion), so overflow isn't a concern here.
|
||||||
|
|
||||||
|
For larger constraints, you might need to interleave addition and subtraction to avoid overflow, or use the XOR approach which never overflows.
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Gauss sum formula**: `n * (n + 1) / 2` gives the sum of integers from `0` to `n` — memorise this for interview problems"
|
||||||
|
- "**Think mathematically**: When you know what the complete set should look like, arithmetic properties can replace searching"
|
||||||
|
- "**XOR alternative**: This problem can also be solved with XOR (a ^ a = 0, a ^ 0 = a) — XOR all indices with all values"
|
||||||
|
- "**Space optimisation**: Many problems with O(n) hash set solutions have O(1) mathematical alternatives"
|
||||||
|
|
||||||
|
time_complexity: "O(n). We traverse the array once to compute the sum."
|
||||||
|
space_complexity: "O(1). We only use a constant number of variables regardless of input size."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Gauss Sum Formula
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def missing_number(nums: list[int]) -> int:
|
||||||
|
n = len(nums)
|
||||||
|
# Expected sum if all numbers 0 to n were present
|
||||||
|
expected_sum = n * (n + 1) // 2
|
||||||
|
# Actual sum of elements in the array
|
||||||
|
actual_sum = sum(nums)
|
||||||
|
# The difference is the missing number
|
||||||
|
return expected_sum - actual_sum
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — Single pass to sum all elements.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — Only storing two integers.
|
||||||
|
|
||||||
|
We use the mathematical formula for the sum of an arithmetic sequence. The missing number is simply the difference between what we expect and what we have.
|
||||||
|
|
||||||
|
- approach_name: XOR Bit Manipulation
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def missing_number(nums: list[int]) -> int:
|
||||||
|
n = len(nums)
|
||||||
|
result = n # Start with n (the largest possible value)
|
||||||
|
|
||||||
|
for i in range(n):
|
||||||
|
# XOR with both index and value
|
||||||
|
result ^= i ^ nums[i]
|
||||||
|
|
||||||
|
return result
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — Single pass through the array.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — Only one variable used.
|
||||||
|
|
||||||
|
This leverages XOR properties: `a ^ a = 0` and `a ^ 0 = a`. By XORing all indices `0` to `n-1` with all values in the array, paired numbers cancel out, leaving only the missing one. We initialise with `n` since indices only go up to `n-1`.
|
||||||
|
|
||||||
|
- approach_name: Hash Set
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def missing_number(nums: list[int]) -> int:
|
||||||
|
num_set = set(nums)
|
||||||
|
n = len(nums)
|
||||||
|
|
||||||
|
# Check each number in range [0, n]
|
||||||
|
for i in range(n + 1):
|
||||||
|
if i not in num_set:
|
||||||
|
return i
|
||||||
|
|
||||||
|
return -1 # Should never reach here
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — Building the set and searching are both O(n).
|
||||||
|
|
||||||
|
**Space Complexity:** O(n) — The hash set stores all n elements.
|
||||||
|
|
||||||
|
This approach uses extra space to enable O(1) lookups. While the time complexity is optimal, the space usage makes it suboptimal compared to the sum or XOR approaches.
|
||||||
211
backend/data/questions/multiply-strings.yaml
Normal file
211
backend/data/questions/multiply-strings.yaml
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
title: Multiply Strings
|
||||||
|
slug: multiply-strings
|
||||||
|
difficulty: medium
|
||||||
|
leetcode_id: 43
|
||||||
|
leetcode_url: https://leetcode.com/problems/multiply-strings/
|
||||||
|
categories:
|
||||||
|
- strings
|
||||||
|
- math
|
||||||
|
patterns:
|
||||||
|
- two-pointers
|
||||||
|
|
||||||
|
description: |
|
||||||
|
Given two non-negative integers `num1` and `num2` represented as strings, return the product of `num1` and `num2`, also represented as a string.
|
||||||
|
|
||||||
|
**Note:** You must not use any built-in BigInteger library or convert the inputs to integer directly.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `1 <= num1.length, num2.length <= 200`
|
||||||
|
- `num1` and `num2` consist of digits only
|
||||||
|
- Both `num1` and `num2` do not contain any leading zero, except the number `0` itself
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: 'num1 = "2", num2 = "3"'
|
||||||
|
output: '"6"'
|
||||||
|
explanation: "2 × 3 = 6"
|
||||||
|
- input: 'num1 = "123", num2 = "456"'
|
||||||
|
output: '"56088"'
|
||||||
|
explanation: "123 × 456 = 56088"
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Think back to how you learned multiplication in primary school — the "long multiplication" method. When you multiply two multi-digit numbers by hand, you don't compute the entire product at once. Instead, you multiply each digit of one number by each digit of the other, keeping track of where each partial product should go based on its position (tens, hundreds, thousands, etc.).
|
||||||
|
|
||||||
|
The key insight is that when you multiply the digit at position `i` of one number with the digit at position `j` of another, the result contributes to positions `i + j` and `i + j + 1` in the final answer. This is because:
|
||||||
|
- A digit at position `i` (from the right, 0-indexed) represents a value of `digit × 10^i`
|
||||||
|
- Multiplying two such digits gives a result that spans two positions in the output
|
||||||
|
|
||||||
|
For example, multiplying `123 × 456`:
|
||||||
|
- `3 × 6 = 18` → contributes to positions 0 and 1 (ones and tens)
|
||||||
|
- `2 × 6 = 12` → contributes to positions 1 and 2 (tens and hundreds)
|
||||||
|
- And so on...
|
||||||
|
|
||||||
|
By accumulating all these partial products into an array and handling carries, we simulate exactly what happens on paper.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We simulate grade-school multiplication using an array to accumulate partial products:
|
||||||
|
|
||||||
|
**Step 1: Handle the zero case**
|
||||||
|
|
||||||
|
- If either `num1` or `num2` is `"0"`, return `"0"` immediately
|
||||||
|
- This avoids unnecessary computation and handles the edge case cleanly
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Initialise the result array**
|
||||||
|
|
||||||
|
- Create an array of size `len(num1) + len(num2)` filled with zeros
|
||||||
|
- The maximum length of the product of two numbers is the sum of their lengths (e.g., `99 × 99 = 9801` has 4 digits, and `len("99") + len("99") = 4`)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Multiply digit by digit**
|
||||||
|
|
||||||
|
- Iterate through `num1` from right to left (index `i`)
|
||||||
|
- For each digit in `num1`, iterate through `num2` from right to left (index `j`)
|
||||||
|
- Convert characters to integers: `digit1 = int(num1[i])`, `digit2 = int(num2[j])`
|
||||||
|
- Compute the product: `product = digit1 × digit2`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 4: Accumulate into the correct positions**
|
||||||
|
|
||||||
|
- The positions in the result array for this product are `i + j` and `i + j + 1`
|
||||||
|
- Add the product to what's already at position `i + j + 1` (the lower position)
|
||||||
|
- Handle the carry: `result[i + j + 1] = sum % 10`, and add `sum // 10` to `result[i + j]`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 5: Convert array to string**
|
||||||
|
|
||||||
|
- Join the digits in the result array
|
||||||
|
- Strip any leading zeros (but keep at least one digit if result is zero)
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Using Built-in Conversion
|
||||||
|
description: |
|
||||||
|
The problem explicitly forbids converting strings to integers directly using `int()` or similar. Solutions like `str(int(num1) * int(num2))` violate the constraints.
|
||||||
|
|
||||||
|
The constraint exists because the input numbers can have up to 200 digits — far exceeding the range of standard 64-bit integers (`2^63 - 1` has only 19 digits). This problem tests whether you can implement arbitrary-precision arithmetic.
|
||||||
|
wrong_approach: "int(num1) * int(num2)"
|
||||||
|
correct_approach: "Simulate digit-by-digit multiplication"
|
||||||
|
|
||||||
|
- title: Off-by-One Position Errors
|
||||||
|
description: |
|
||||||
|
When multiplying digit at index `i` with digit at index `j`, remember that indices are from the left but place values are from the right.
|
||||||
|
|
||||||
|
If you iterate left-to-right but use `i + j` directly, you'll place digits in the wrong positions. Either reverse the strings first, or use `(len1 - 1 - i) + (len2 - 1 - j)` to convert to place value positions.
|
||||||
|
|
||||||
|
The cleanest approach: iterate from right to left, then `i + j + 1` and `i + j` give the correct output positions.
|
||||||
|
wrong_approach: "Using indices directly without accounting for place value"
|
||||||
|
correct_approach: "Iterate right-to-left or adjust indices for place value"
|
||||||
|
|
||||||
|
- title: Forgetting to Handle Carries Properly
|
||||||
|
description: |
|
||||||
|
Each digit position can accumulate more than 9 during the multiplication phase. You must propagate carries correctly.
|
||||||
|
|
||||||
|
A common mistake is handling the carry only at the end, or forgetting that intermediate sums can exceed 9. Process the carry as you go by using `sum % 10` for the current position and `sum // 10` for the next position.
|
||||||
|
wrong_approach: "Ignoring carries until the final conversion"
|
||||||
|
correct_approach: "Handle carry during each digit multiplication"
|
||||||
|
|
||||||
|
- title: Leading Zeros in Result
|
||||||
|
description: |
|
||||||
|
The result array may have leading zeros (e.g., `099` for `9 × 11 = 99`). You must strip these before returning.
|
||||||
|
|
||||||
|
However, be careful not to strip all zeros if the result is `0` — ensure at least one zero remains for the `"0"` case.
|
||||||
|
wrong_approach: "Returning result with leading zeros"
|
||||||
|
correct_approach: "Strip leading zeros but preserve '0' for zero result"
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Simulate manual algorithms**: When built-ins are forbidden, implement the algorithm you'd use by hand — here, grade-school long multiplication"
|
||||||
|
- "**Position mapping**: The product of digits at positions `i` and `j` contributes to positions `i + j` and `i + j + 1` — this is the core insight"
|
||||||
|
- "**Arbitrary precision**: This technique handles numbers far larger than native integer types can store"
|
||||||
|
- "**Foundation for big integer libraries**: This is essentially how BigInteger multiplication works under the hood (with optimisations like Karatsuba for very large numbers)"
|
||||||
|
|
||||||
|
time_complexity: "O(m × n). We multiply each digit of `num1` (length m) with each digit of `num2` (length n)."
|
||||||
|
space_complexity: "O(m + n). We use an array of size `m + n` to store the result digits."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Grade-School Multiplication
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def multiply(num1: str, num2: str) -> str:
|
||||||
|
# Handle multiplication by zero
|
||||||
|
if num1 == "0" or num2 == "0":
|
||||||
|
return "0"
|
||||||
|
|
||||||
|
m, n = len(num1), len(num2)
|
||||||
|
# Result can have at most m + n digits
|
||||||
|
result = [0] * (m + n)
|
||||||
|
|
||||||
|
# Multiply each digit, right to left
|
||||||
|
for i in range(m - 1, -1, -1):
|
||||||
|
for j in range(n - 1, -1, -1):
|
||||||
|
# Get the actual digit values
|
||||||
|
digit1 = ord(num1[i]) - ord('0')
|
||||||
|
digit2 = ord(num2[j]) - ord('0')
|
||||||
|
|
||||||
|
# Multiply and add to existing value at this position
|
||||||
|
product = digit1 * digit2
|
||||||
|
# Positions in result array
|
||||||
|
p1, p2 = i + j, i + j + 1
|
||||||
|
|
||||||
|
# Add product to the lower position
|
||||||
|
total = product + result[p2]
|
||||||
|
|
||||||
|
# Store digit and carry
|
||||||
|
result[p2] = total % 10
|
||||||
|
result[p1] += total // 10
|
||||||
|
|
||||||
|
# Convert to string, skipping leading zeros
|
||||||
|
result_str = ''.join(map(str, result))
|
||||||
|
return result_str.lstrip('0') or '0'
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(m × n) — We perform m × n single-digit multiplications.
|
||||||
|
|
||||||
|
**Space Complexity:** O(m + n) — The result array stores the product digits.
|
||||||
|
|
||||||
|
This approach simulates the long multiplication method taught in schools. We multiply each digit pair and accumulate results at the appropriate positions, handling carries as we go. The key insight is that digits at indices `i` and `j` contribute to positions `i + j` and `i + j + 1` in the result.
|
||||||
|
|
||||||
|
- approach_name: With Separate Carry Pass
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def multiply(num1: str, num2: str) -> str:
|
||||||
|
if num1 == "0" or num2 == "0":
|
||||||
|
return "0"
|
||||||
|
|
||||||
|
m, n = len(num1), len(num2)
|
||||||
|
# Accumulate products without handling carries yet
|
||||||
|
result = [0] * (m + n)
|
||||||
|
|
||||||
|
# Reverse for easier position calculation
|
||||||
|
num1 = num1[::-1]
|
||||||
|
num2 = num2[::-1]
|
||||||
|
|
||||||
|
# Multiply all digit pairs
|
||||||
|
for i in range(m):
|
||||||
|
for j in range(n):
|
||||||
|
digit1 = ord(num1[i]) - ord('0')
|
||||||
|
digit2 = ord(num2[j]) - ord('0')
|
||||||
|
# Position i + j gets this product
|
||||||
|
result[i + j] += digit1 * digit2
|
||||||
|
|
||||||
|
# Now handle all carries in a separate pass
|
||||||
|
carry = 0
|
||||||
|
for i in range(len(result)):
|
||||||
|
total = result[i] + carry
|
||||||
|
result[i] = total % 10
|
||||||
|
carry = total // 10
|
||||||
|
|
||||||
|
# Remove trailing zeros (they're leading after we reverse)
|
||||||
|
while len(result) > 1 and result[-1] == 0:
|
||||||
|
result.pop()
|
||||||
|
|
||||||
|
# Reverse back and convert to string
|
||||||
|
return ''.join(map(str, result[::-1]))
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(m × n) — Same asymptotic complexity as the optimal approach.
|
||||||
|
|
||||||
|
**Space Complexity:** O(m + n) — Same space usage.
|
||||||
|
|
||||||
|
This alternative reverses the strings first for simpler position indexing, accumulates all products without immediate carry handling, then propagates carries in a separate pass. While equally efficient, it's slightly less elegant than handling carries inline. Included to show a valid alternative approach.
|
||||||
209
backend/data/questions/network-delay-time.yaml
Normal file
209
backend/data/questions/network-delay-time.yaml
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
title: Network Delay Time
|
||||||
|
slug: network-delay-time
|
||||||
|
difficulty: medium
|
||||||
|
leetcode_id: 743
|
||||||
|
leetcode_url: https://leetcode.com/problems/network-delay-time/
|
||||||
|
categories:
|
||||||
|
- graphs
|
||||||
|
- heap
|
||||||
|
patterns:
|
||||||
|
- bfs
|
||||||
|
- heap
|
||||||
|
|
||||||
|
description: |
|
||||||
|
You are given a network of `n` nodes, labeled from `1` to `n`. You are also given `times`, a list of travel times as directed edges `times[i] = (u_i, v_i, w_i)`, where `u_i` is the source node, `v_i` is the target node, and `w_i` is the time it takes for a signal to travel from source to target.
|
||||||
|
|
||||||
|
We will send a signal from a given node `k`. Return *the **minimum** time it takes for all the* `n` *nodes to receive the signal*. If it is impossible for all the `n` nodes to receive the signal, return `-1`.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `1 <= k <= n <= 100`
|
||||||
|
- `1 <= times.length <= 6000`
|
||||||
|
- `times[i].length == 3`
|
||||||
|
- `1 <= u_i, v_i <= n`
|
||||||
|
- `u_i != v_i`
|
||||||
|
- `0 <= w_i <= 100`
|
||||||
|
- All the pairs `(u_i, v_i)` are **unique** (i.e., no multiple edges)
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: "times = [[2,1,1],[2,3,1],[3,4,1]], n = 4, k = 2"
|
||||||
|
output: "2"
|
||||||
|
explanation: "Starting from node 2, the signal reaches node 1 and node 3 at time 1. The signal then travels from node 3 to node 4, arriving at time 2. The maximum time to reach any node is 2."
|
||||||
|
- input: "times = [[1,2,1]], n = 2, k = 1"
|
||||||
|
output: "1"
|
||||||
|
explanation: "Starting from node 1, the signal reaches node 2 at time 1."
|
||||||
|
- input: "times = [[1,2,1]], n = 2, k = 2"
|
||||||
|
output: "-1"
|
||||||
|
explanation: "Starting from node 2, there is no edge leading to node 1, so node 1 can never receive the signal."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Imagine you're standing at a train station and want to know the fastest time to reach every other station in a railway network. Trains run at different speeds between stations — some routes are faster than others.
|
||||||
|
|
||||||
|
The key insight is that **this is a single-source shortest path problem**. We need to find the shortest time from the starting node `k` to every other node in the graph. The answer is then the maximum of all these shortest times — because that's when the last node receives the signal.
|
||||||
|
|
||||||
|
Think of the signal spreading like ripples in a pond, but with weighted edges. The signal doesn't spread at uniform speed; it travels faster along some paths than others. We need to track when each node *first* receives the signal, which is the shortest path from `k` to that node.
|
||||||
|
|
||||||
|
**Dijkstra's algorithm** is perfect here. It greedily processes nodes in order of their distance from the source, guaranteeing that when we first visit a node, we've found the shortest path to it. Since all edge weights are non-negative (`0 <= w_i <= 100`), Dijkstra's algorithm works correctly.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using **Dijkstra's Algorithm** with a min-heap:
|
||||||
|
|
||||||
|
**Step 1: Build the adjacency list**
|
||||||
|
|
||||||
|
- Create a graph representation where `graph[u]` contains all `(v, w)` pairs representing edges from `u` to `v` with weight `w`
|
||||||
|
- This allows O(1) lookup of all neighbors for any node
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Initialise data structures**
|
||||||
|
|
||||||
|
- `dist`: A dictionary or array to store the shortest known distance to each node (initially empty or infinity)
|
||||||
|
- `heap`: A min-heap (priority queue) containing `(distance, node)` pairs
|
||||||
|
- Push the starting node `k` with distance `0` onto the heap
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Process nodes in order of distance**
|
||||||
|
|
||||||
|
- Pop the node with the smallest distance from the heap
|
||||||
|
- If we've already visited this node (found in `dist`), skip it — we already found a shorter path
|
||||||
|
- Otherwise, record this distance as the shortest path to this node
|
||||||
|
- For each neighbor, calculate the new distance through this node
|
||||||
|
- If we haven't visited the neighbor yet, push `(new_distance, neighbor)` onto the heap
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 4: Check if all nodes are reachable**
|
||||||
|
|
||||||
|
- After processing, if we've visited all `n` nodes, return the maximum distance
|
||||||
|
- If any node is unreachable, return `-1`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
The heap ensures we always process the node with the smallest known distance first, which is the key to Dijkstra's correctness.
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Using BFS on a Weighted Graph
|
||||||
|
description: |
|
||||||
|
A common mistake is treating this like a standard BFS problem. BFS finds shortest paths in **unweighted** graphs (or graphs where all edges have the same weight).
|
||||||
|
|
||||||
|
With varying edge weights, BFS might visit a node through a path with fewer edges but higher total weight. For example, a direct edge with weight 10 would be processed before a two-hop path with total weight 2.
|
||||||
|
|
||||||
|
Always use Dijkstra (or Bellman-Ford) for weighted shortest paths.
|
||||||
|
wrong_approach: "Standard BFS without considering edge weights"
|
||||||
|
correct_approach: "Dijkstra's algorithm with a min-heap priority queue"
|
||||||
|
|
||||||
|
- title: Forgetting Node Labels Start at 1
|
||||||
|
description: |
|
||||||
|
The nodes are labeled `1` to `n`, not `0` to `n-1`. If you use a 0-indexed array for distances, you'll have off-by-one errors.
|
||||||
|
|
||||||
|
Either use a dictionary for distances, or create an array of size `n+1` and ignore index 0.
|
||||||
|
wrong_approach: "Using 0-indexed array without adjustment"
|
||||||
|
correct_approach: "Use a dictionary or 1-indexed array"
|
||||||
|
|
||||||
|
- title: Not Handling Unreachable Nodes
|
||||||
|
description: |
|
||||||
|
The graph may be disconnected — not all nodes may be reachable from `k`. You must check that all `n` nodes received the signal before returning the maximum time.
|
||||||
|
|
||||||
|
If `len(dist) < n` after Dijkstra completes, some nodes are unreachable, and the answer is `-1`.
|
||||||
|
wrong_approach: "Returning max distance without checking reachability"
|
||||||
|
correct_approach: "Verify all n nodes are in the distance dictionary"
|
||||||
|
|
||||||
|
- title: Re-processing Already Visited Nodes
|
||||||
|
description: |
|
||||||
|
The same node can be pushed onto the heap multiple times with different distances. When you pop a node, check if it's already been finalized (exists in your distance map).
|
||||||
|
|
||||||
|
Without this check, you might process the same node repeatedly, leading to incorrect results or TLE.
|
||||||
|
wrong_approach: "Processing every node popped from the heap"
|
||||||
|
correct_approach: "Skip nodes that have already been assigned a shortest distance"
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Dijkstra's algorithm** is the go-to for single-source shortest paths with non-negative weights"
|
||||||
|
- "**Min-heap** ensures we always process the closest unvisited node, maintaining the greedy invariant"
|
||||||
|
- "The answer to 'time for all nodes to receive signal' is the **maximum** of all shortest paths"
|
||||||
|
- "This pattern appears in many variations: minimum cost to reach destinations, cheapest flights, etc."
|
||||||
|
|
||||||
|
time_complexity: "O((V + E) log V). Each node is processed once, and each edge triggers at most one heap operation. With `n` nodes and `m` edges, this is O((n + m) log n)."
|
||||||
|
space_complexity: "O(V + E). We store the adjacency list (O(E)) and the distance dictionary plus heap (O(V))."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Dijkstra's Algorithm
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
import heapq
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
def network_delay_time(times: list[list[int]], n: int, k: int) -> int:
|
||||||
|
# Build adjacency list: graph[u] = [(v, w), ...]
|
||||||
|
graph = defaultdict(list)
|
||||||
|
for u, v, w in times:
|
||||||
|
graph[u].append((v, w))
|
||||||
|
|
||||||
|
# Min-heap: (distance, node), starting from node k
|
||||||
|
heap = [(0, k)]
|
||||||
|
# Dictionary to store shortest distance to each node
|
||||||
|
dist = {}
|
||||||
|
|
||||||
|
while heap:
|
||||||
|
# Get the node with smallest distance
|
||||||
|
d, node = heapq.heappop(heap)
|
||||||
|
|
||||||
|
# Skip if we've already found a shorter path to this node
|
||||||
|
if node in dist:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Record the shortest distance to this node
|
||||||
|
dist[node] = d
|
||||||
|
|
||||||
|
# Explore all neighbors
|
||||||
|
for neighbor, weight in graph[node]:
|
||||||
|
if neighbor not in dist:
|
||||||
|
# Push new distance to neighbor onto heap
|
||||||
|
heapq.heappush(heap, (d + weight, neighbor))
|
||||||
|
|
||||||
|
# Check if all nodes are reachable
|
||||||
|
if len(dist) == n:
|
||||||
|
# Return the maximum distance (time for last node to receive signal)
|
||||||
|
return max(dist.values())
|
||||||
|
else:
|
||||||
|
# Some nodes are unreachable
|
||||||
|
return -1
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O((V + E) log V) — Each edge may cause a heap push (O(log V)), and we process each node once.
|
||||||
|
|
||||||
|
**Space Complexity:** O(V + E) — The adjacency list stores all edges, and the heap/distance dict store at most V entries.
|
||||||
|
|
||||||
|
Dijkstra's algorithm processes nodes in order of their distance from the source. The min-heap ensures we always pick the closest unvisited node. When we pop a node, we've guaranteed found the shortest path to it (since all edge weights are non-negative).
|
||||||
|
|
||||||
|
- approach_name: Bellman-Ford Algorithm
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def network_delay_time(times: list[list[int]], n: int, k: int) -> int:
|
||||||
|
# Initialise distances: infinity for all except source
|
||||||
|
dist = [float('inf')] * (n + 1)
|
||||||
|
dist[k] = 0
|
||||||
|
|
||||||
|
# Relax all edges n-1 times
|
||||||
|
for _ in range(n - 1):
|
||||||
|
# Flag to detect early termination
|
||||||
|
updated = False
|
||||||
|
for u, v, w in times:
|
||||||
|
# If we can reach v faster through u
|
||||||
|
if dist[u] != float('inf') and dist[u] + w < dist[v]:
|
||||||
|
dist[v] = dist[u] + w
|
||||||
|
updated = True
|
||||||
|
# Early exit if no updates in this round
|
||||||
|
if not updated:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Find maximum distance (excluding index 0 and unreachable nodes)
|
||||||
|
max_dist = max(dist[1:])
|
||||||
|
|
||||||
|
# If any node is unreachable, return -1
|
||||||
|
return max_dist if max_dist < float('inf') else -1
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(V * E) — We relax all edges up to V-1 times. With n=100 and edges up to 6000, this is about 600,000 operations.
|
||||||
|
|
||||||
|
**Space Complexity:** O(V) — We only store the distance array.
|
||||||
|
|
||||||
|
Bellman-Ford works by repeatedly relaxing all edges. After `n-1` iterations, all shortest paths are found (since the longest simple path has at most `n-1` edges). It's slower than Dijkstra but can handle negative edge weights. Here it's shown as an alternative approach.
|
||||||
183
backend/data/questions/number-of-1-bits.yaml
Normal file
183
backend/data/questions/number-of-1-bits.yaml
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
title: Number of 1 Bits
|
||||||
|
slug: number-of-1-bits
|
||||||
|
difficulty: easy
|
||||||
|
leetcode_id: 191
|
||||||
|
leetcode_url: https://leetcode.com/problems/number-of-1-bits/
|
||||||
|
categories:
|
||||||
|
- math
|
||||||
|
patterns: []
|
||||||
|
|
||||||
|
function_signature: "def hamming_weight(n: int) -> int:"
|
||||||
|
|
||||||
|
test_cases:
|
||||||
|
visible:
|
||||||
|
- input: { n: 11 }
|
||||||
|
expected: 3
|
||||||
|
- input: { n: 128 }
|
||||||
|
expected: 1
|
||||||
|
- input: { n: 1 }
|
||||||
|
expected: 1
|
||||||
|
hidden:
|
||||||
|
- input: { n: 7 }
|
||||||
|
expected: 3
|
||||||
|
- input: { n: 255 }
|
||||||
|
expected: 8
|
||||||
|
- input: { n: 16 }
|
||||||
|
expected: 1
|
||||||
|
|
||||||
|
description: |
|
||||||
|
Given a positive integer `n`, write a function that returns the number of **set bits** in its binary representation (also known as the [Hamming weight](http://en.wikipedia.org/wiki/Hamming_weight)).
|
||||||
|
|
||||||
|
A *set bit* is a bit that has a value of `1`.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `1 <= n <= 2^31 - 1`
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: "n = 11"
|
||||||
|
output: "3"
|
||||||
|
explanation: "The binary representation of 11 is `1011`, which has three set bits."
|
||||||
|
- input: "n = 128"
|
||||||
|
output: "1"
|
||||||
|
explanation: "The binary representation of 128 is `10000000`, which has one set bit."
|
||||||
|
- input: "n = 2147483645"
|
||||||
|
output: "30"
|
||||||
|
explanation: "The binary representation has 30 set bits (all bits except the 2nd position are 1)."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Imagine each bit in the binary representation as a light switch — either on (`1`) or off (`0`). Your task is simply to count how many switches are in the "on" position.
|
||||||
|
|
||||||
|
The naive approach would be to check each of the 32 bit positions one by one. But there's a clever trick using bit manipulation that lets you count *only* the `1` bits, skipping all the `0`s entirely.
|
||||||
|
|
||||||
|
The key insight is the operation `n & (n - 1)`. Subtracting `1` from a number flips all the bits from the rightmost `1` bit to the end. When you AND the original number with `n - 1`, the rightmost `1` bit gets cleared. For example:
|
||||||
|
- `12` in binary is `1100`
|
||||||
|
- `11` in binary is `1011`
|
||||||
|
- `12 & 11 = 1000` — the rightmost `1` bit is gone!
|
||||||
|
|
||||||
|
By repeatedly clearing the rightmost `1` bit and counting how many times we do this, we get the exact count of set bits. This approach is proportional to the number of `1` bits, not the total number of bits.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using the **Brian Kernighan's Algorithm**:
|
||||||
|
|
||||||
|
**Step 1: Initialise the counter**
|
||||||
|
|
||||||
|
- `count`: Set to `0` to track the number of set bits
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Loop while n is not zero**
|
||||||
|
|
||||||
|
- While `n > 0`:
|
||||||
|
- Apply `n = n & (n - 1)` — this clears the rightmost set bit
|
||||||
|
- Increment `count` by `1`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Return the count**
|
||||||
|
|
||||||
|
- Return `count` which now holds the number of `1` bits
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
This algorithm is elegant because it only iterates as many times as there are set bits. A number with just one `1` bit (like `128`) completes in one iteration, while a number with many `1` bits takes proportionally more.
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Checking All 32 Bits
|
||||||
|
description: |
|
||||||
|
A common approach is to loop through all 32 bits and check each one:
|
||||||
|
|
||||||
|
```
|
||||||
|
for i in range(32):
|
||||||
|
if n & (1 << i):
|
||||||
|
count += 1
|
||||||
|
```
|
||||||
|
|
||||||
|
While this works and has O(1) time (constant 32 iterations), it's less efficient than the `n & (n - 1)` approach when there are few set bits.
|
||||||
|
|
||||||
|
For example, the number `128` has only one set bit. The bit-by-bit approach still checks all 32 positions, while Brian Kernighan's algorithm finishes in just one iteration.
|
||||||
|
wrong_approach: "Loop through all 32 bit positions"
|
||||||
|
correct_approach: "Use n & (n - 1) to clear bits one at a time"
|
||||||
|
|
||||||
|
- title: Using Right Shift with Signed Integers
|
||||||
|
description: |
|
||||||
|
In some languages, using `n >> 1` on a negative number (or treating the input as signed) can cause issues due to sign extension — the sign bit gets replicated.
|
||||||
|
|
||||||
|
Python handles arbitrary-precision integers, so this isn't an issue in Python. But in languages like C or Java, you'd want to use unsigned right shift (`>>>`) or work with unsigned types to avoid infinite loops with numbers that have the high bit set.
|
||||||
|
wrong_approach: "Using signed right shift on potentially negative values"
|
||||||
|
correct_approach: "Use unsigned operations or the n & (n - 1) trick"
|
||||||
|
|
||||||
|
- title: Forgetting Zero Has No Set Bits
|
||||||
|
description: |
|
||||||
|
While the problem states `n >= 1`, it's worth noting that `0` has zero set bits. If your algorithm starts with `count = 1` instead of `count = 0`, or doesn't handle the case where `n` is already zero, you could return incorrect results.
|
||||||
|
|
||||||
|
The `n & (n - 1)` approach handles this naturally — if `n = 0`, the loop never executes and returns `0`.
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Brian Kernighan's Algorithm**: `n & (n - 1)` clears the rightmost set bit — a fundamental bit manipulation trick"
|
||||||
|
- "**Efficiency proportional to set bits**: The algorithm runs in O(k) time where k is the number of `1` bits, not the total bit width"
|
||||||
|
- "**Hamming weight applications**: Counting set bits is used in error detection, cryptography, and similarity measures between binary strings"
|
||||||
|
- "**Follow-up optimisation**: For repeated calls, precompute a lookup table for bytes (256 entries) and process the number 8 bits at a time"
|
||||||
|
|
||||||
|
time_complexity: "O(k) where k is the number of set bits. Each iteration clears exactly one `1` bit, so we loop at most k times (maximum 31 for a 32-bit integer)."
|
||||||
|
space_complexity: "O(1). We only use a single counter variable regardless of the input size."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Brian Kernighan's Algorithm
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def hamming_weight(n: int) -> int:
|
||||||
|
# Count of set bits
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
while n:
|
||||||
|
# n & (n - 1) clears the rightmost set bit
|
||||||
|
# This works because n - 1 flips all bits from
|
||||||
|
# the rightmost 1 to the end
|
||||||
|
n = n & (n - 1)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
return count
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(k) — where k is the number of set bits (at most 31).
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — only a counter variable.
|
||||||
|
|
||||||
|
The trick `n & (n - 1)` exploits the binary representation: subtracting 1 flips the rightmost `1` and all bits after it, so AND-ing with the original clears exactly that bit. We count how many times we can do this before `n` becomes zero.
|
||||||
|
|
||||||
|
- approach_name: Bit-by-Bit Check
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def hamming_weight(n: int) -> int:
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
# Check each of the 32 bit positions
|
||||||
|
while n:
|
||||||
|
# Check if the last bit is 1
|
||||||
|
count += n & 1
|
||||||
|
# Right shift to check the next bit
|
||||||
|
n >>= 1
|
||||||
|
|
||||||
|
return count
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(log n) — or O(32) for a 32-bit integer.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — only a counter variable.
|
||||||
|
|
||||||
|
This approach checks each bit by AND-ing with `1` and right-shifting. It always processes all bits of the number, unlike Brian Kernighan's algorithm which only iterates once per set bit.
|
||||||
|
|
||||||
|
- approach_name: Built-in Population Count
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def hamming_weight(n: int) -> int:
|
||||||
|
# Python's bit_count() method (Python 3.10+)
|
||||||
|
return n.bit_count()
|
||||||
|
|
||||||
|
# Alternative using bin() string manipulation
|
||||||
|
# return bin(n).count('1')
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(1) — built-in operations are highly optimised, often using CPU popcount instructions.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — no additional space.
|
||||||
|
|
||||||
|
Modern Python (3.10+) provides `int.bit_count()` which uses optimised CPU instructions when available. The `bin(n).count('1')` alternative creates a string, making it less efficient. While practical for production code, understanding the underlying algorithms is valuable for interviews.
|
||||||
147
backend/data/questions/one-bit-and-two-bit-characters.yaml
Normal file
147
backend/data/questions/one-bit-and-two-bit-characters.yaml
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
title: 1-bit and 2-bit Characters
|
||||||
|
slug: one-bit-and-two-bit-characters
|
||||||
|
difficulty: easy
|
||||||
|
leetcode_id: 717
|
||||||
|
leetcode_url: https://leetcode.com/problems/1-bit-and-2-bit-characters/
|
||||||
|
categories:
|
||||||
|
- arrays
|
||||||
|
patterns:
|
||||||
|
- greedy
|
||||||
|
|
||||||
|
description: |
|
||||||
|
We have two special characters:
|
||||||
|
|
||||||
|
- The first character can be represented by one bit `0`.
|
||||||
|
- The second character can be represented by two bits (`10` or `11`).
|
||||||
|
|
||||||
|
Given a binary array `bits` that ends with `0`, return `true` if the last character must be a one-bit character.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `1 <= bits.length <= 1000`
|
||||||
|
- `bits[i]` is either `0` or `1`.
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: "bits = [1,0,0]"
|
||||||
|
output: "true"
|
||||||
|
explanation: "The only way to decode it is two-bit character (10) and one-bit character (0). So the last character is one-bit character."
|
||||||
|
- input: "bits = [1,1,1,0]"
|
||||||
|
output: "false"
|
||||||
|
explanation: "The only way to decode it is two-bit character (11) and two-bit character (10). So the last character is NOT a one-bit character."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Imagine you're reading a coded message where each character is either a single `0` or a pair starting with `1` (`10` or `11`). Your job is to determine if the very last `0` in the array stands alone as its own character.
|
||||||
|
|
||||||
|
The key insight is that **a `1` always consumes the next bit**. When you see a `1`, you must read two bits together as one character. When you see a `0`, it stands alone as a one-bit character.
|
||||||
|
|
||||||
|
Think of it like this: walk through the array from left to right. Every time you encounter a `1`, skip ahead by 2 positions (you've consumed a two-bit character). Every time you encounter a `0`, skip ahead by 1 position (you've consumed a one-bit character).
|
||||||
|
|
||||||
|
If this greedy decoding process lands you exactly on the last index, then that final `0` must be a standalone one-bit character. If you land past it (by skipping over it as part of a two-bit character), the answer is `false`.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using a **Greedy Linear Scan**:
|
||||||
|
|
||||||
|
**Step 1: Initialise the position pointer**
|
||||||
|
|
||||||
|
- `i`: Set to `0` to start at the beginning of the array
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Iterate through the array (except the last element)**
|
||||||
|
|
||||||
|
- While `i < len(bits) - 1` (we stop before the last element):
|
||||||
|
- If `bits[i] == 1`: This starts a two-bit character, so jump ahead by 2 (`i += 2`)
|
||||||
|
- If `bits[i] == 0`: This is a one-bit character, so jump ahead by 1 (`i += 1`)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Check the final position**
|
||||||
|
|
||||||
|
- After the loop, check if `i == len(bits) - 1`
|
||||||
|
- If true, we landed exactly on the last bit, meaning it's a standalone one-bit character
|
||||||
|
- If false (we landed past the last index), the last `0` was consumed as part of a two-bit character
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
This greedy approach works because the decoding is **deterministic** — there's only one valid way to decode any given sequence, and a `1` always forces a two-bit read.
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Trying to Decode Backwards
|
||||||
|
description: |
|
||||||
|
It might seem intuitive to start from the end and work backwards, but this approach is tricky because characters are defined by their **first** bit, not their last.
|
||||||
|
|
||||||
|
For example, in `[1, 0, 0]`, working backwards you'd see a `0` and wonder if it's standalone or the second half of `10`. The encoding is designed to be read forward, so decode forward.
|
||||||
|
wrong_approach: "Iterating from the end of the array"
|
||||||
|
correct_approach: "Iterate from the start, following the encoding rules"
|
||||||
|
|
||||||
|
- title: Counting Ones Before the Last Zero
|
||||||
|
description: |
|
||||||
|
Some solutions try to count consecutive `1`s before the final `0` and check if the count is odd or even. While this can work, it's error-prone and less intuitive than simulating the actual decoding process.
|
||||||
|
|
||||||
|
The simulation approach directly models the problem and is harder to get wrong.
|
||||||
|
wrong_approach: "Counting 1s and using modular arithmetic"
|
||||||
|
correct_approach: "Simulate the decoding by jumping 1 or 2 positions"
|
||||||
|
|
||||||
|
- title: Off-by-One Errors
|
||||||
|
description: |
|
||||||
|
A common mistake is iterating until `i < len(bits)` instead of `i < len(bits) - 1`. This causes the loop to potentially decode the last element, which defeats the purpose of checking whether we land on it.
|
||||||
|
|
||||||
|
We must stop **before** the last element to see if our decoding naturally lands on it.
|
||||||
|
wrong_approach: "Loop while i < len(bits)"
|
||||||
|
correct_approach: "Loop while i < len(bits) - 1"
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Greedy simulation**: When a sequence has deterministic decoding rules, simulate the process step by step"
|
||||||
|
- "**Follow the encoding direction**: This encoding is designed to be read left-to-right; decode in the same direction"
|
||||||
|
- "**Jump patterns**: Recognise when elements dictate variable-length jumps (1 consumes 2 positions, 0 consumes 1)"
|
||||||
|
- "**Boundary condition**: The key insight is checking WHERE you land after decoding, not WHAT you decode"
|
||||||
|
|
||||||
|
time_complexity: "O(n). We traverse the array once, visiting each element at most once."
|
||||||
|
space_complexity: "O(1). We only use a single pointer variable `i`, regardless of input size."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Greedy Linear Scan
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def is_one_bit_character(bits: list[int]) -> bool:
|
||||||
|
i = 0
|
||||||
|
# Decode all characters except potentially the last one
|
||||||
|
while i < len(bits) - 1:
|
||||||
|
if bits[i] == 1:
|
||||||
|
# Two-bit character: skip the next bit too
|
||||||
|
i += 2
|
||||||
|
else:
|
||||||
|
# One-bit character: move to next position
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# If we land exactly on the last index, it's a one-bit character
|
||||||
|
return i == len(bits) - 1
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — Single pass through the array.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — Only one integer variable used.
|
||||||
|
|
||||||
|
We greedily decode the array from left to right. A `1` forces us to consume two bits, while a `0` consumes just one. After processing all characters before the last position, we check if we've landed exactly on the final index.
|
||||||
|
|
||||||
|
- approach_name: Count Ones Before Last Zero
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def is_one_bit_character(bits: list[int]) -> bool:
|
||||||
|
# Count consecutive 1s immediately before the last 0
|
||||||
|
ones_count = 0
|
||||||
|
# Start from second-to-last element, go backwards
|
||||||
|
for i in range(len(bits) - 2, -1, -1):
|
||||||
|
if bits[i] == 1:
|
||||||
|
ones_count += 1
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
# If even number of 1s, they pair up, leaving last 0 alone
|
||||||
|
# If odd number of 1s, the last 1 pairs with the final 0
|
||||||
|
return ones_count % 2 == 0
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — Worst case, all elements before the last are `1`.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — Only a counter variable.
|
||||||
|
|
||||||
|
This approach counts consecutive `1`s immediately preceding the final `0`. If the count is even, all `1`s pair up with each other (forming `11` characters), leaving the last `0` standalone. If odd, one `1` must pair with the final `0` (forming `10`), making the last character two-bit. While correct, this is less intuitive than simulating the decoding process.
|
||||||
164
backend/data/questions/online-stock-span.yaml
Normal file
164
backend/data/questions/online-stock-span.yaml
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
title: Online Stock Span
|
||||||
|
slug: online-stock-span
|
||||||
|
difficulty: medium
|
||||||
|
leetcode_id: 901
|
||||||
|
leetcode_url: https://leetcode.com/problems/online-stock-span/
|
||||||
|
categories:
|
||||||
|
- stack
|
||||||
|
patterns:
|
||||||
|
- monotonic-stack
|
||||||
|
|
||||||
|
description: |
|
||||||
|
Design an algorithm that collects daily price quotes for some stock and returns **the span** of that stock's price for the current day.
|
||||||
|
|
||||||
|
The **span** of the stock's price in one day is the maximum number of consecutive days (starting from that day and going backward) for which the stock price was less than or equal to the price of that day.
|
||||||
|
|
||||||
|
- For example, if the prices of the stock in the last four days is `[7,2,1,2]` and the price of the stock today is `2`, then the span of today is `4` because starting from today, the price of the stock was less than or equal `2` for `4` consecutive days.
|
||||||
|
- Also, if the prices of the stock in the last four days is `[7,34,1,2]` and the price of the stock today is `8`, then the span of today is `3` because starting from today, the price of the stock was less than or equal `8` for `3` consecutive days.
|
||||||
|
|
||||||
|
Implement the `StockSpanner` class:
|
||||||
|
|
||||||
|
- `StockSpanner()` Initialises the object of the class.
|
||||||
|
- `int next(int price)` Returns the **span** of the stock's price given that today's price is `price`.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `1 <= price <= 10^5`
|
||||||
|
- At most `10^4` calls will be made to `next`
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: '["StockSpanner", "next", "next", "next", "next", "next", "next", "next"], [[], [100], [80], [60], [70], [60], [75], [85]]'
|
||||||
|
output: "[null, 1, 1, 1, 2, 1, 4, 6]"
|
||||||
|
explanation: "stockSpanner.next(100) returns 1, next(80) returns 1, next(60) returns 1, next(70) returns 2 (spans days with prices 60 and 70), next(60) returns 1, next(75) returns 4 (spans 60, 70, 60, 75), next(85) returns 6 (spans 60, 70, 60, 75, 80, 85... wait, let's trace: prices seen are [100, 80, 60, 70, 60, 75, 85]. For 85, going back: 75<=85 (span 4), 60<=85 (span 1), 70<=85 (span 2), 60<=85 (span 1), 80<=85 (span 1). Total = 1+4+1 = 6)."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Imagine you're a stock analyst looking at a chart of daily prices. For each new price, you need to count how many consecutive days (looking backward) had prices less than or equal to today's price.
|
||||||
|
|
||||||
|
The naive approach would be to look back day by day each time a new price arrives. But this is inefficient — if prices keep increasing, you'd repeatedly scan through the same history.
|
||||||
|
|
||||||
|
Here's the key insight: **once a smaller price is "absorbed" into a larger price's span, you never need to look at it again**. If today's price is 85 and yesterday's was 75, then any future price ≥ 85 will automatically include yesterday's span too.
|
||||||
|
|
||||||
|
Think of it like a game where smaller prices get "swallowed" by larger ones. We only need to remember prices that could potentially stop a future price's span — these are prices in **decreasing order**. This is exactly what a *monotonic decreasing stack* tracks.
|
||||||
|
|
||||||
|
The stack stores pairs of `(price, span)`. When a new price arrives, we pop all smaller-or-equal prices and accumulate their spans. The remaining stack top (if any) is the first price larger than today's — that's where our span stops.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using a **Monotonic Decreasing Stack**:
|
||||||
|
|
||||||
|
**Step 1: Initialise the data structure**
|
||||||
|
|
||||||
|
- `stack`: A list storing tuples of `(price, span)` in decreasing price order
|
||||||
|
- The stack will always maintain prices in strictly decreasing order from bottom to top
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Process each new price in the `next` method**
|
||||||
|
|
||||||
|
- Start with `span = 1` (the current day counts)
|
||||||
|
- While the stack is non-empty AND the top price is ≤ current price:
|
||||||
|
- Pop the top element
|
||||||
|
- Add its span to our current span (we "absorb" those days)
|
||||||
|
- Push `(price, span)` onto the stack
|
||||||
|
- Return the calculated span
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Understanding why this works**
|
||||||
|
|
||||||
|
- When we pop smaller prices, we're effectively saying "these days are now part of the current day's span"
|
||||||
|
- We store the accumulated span with each price, so we don't need to revisit individual days
|
||||||
|
- The stack only keeps prices that are strictly greater than all prices that came after them
|
||||||
|
- Each price is pushed once and popped at most once, giving amortised O(1) per operation
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Scanning Backward Each Time
|
||||||
|
description: |
|
||||||
|
The brute force approach stores all prices in a list and scans backward from the current index to count consecutive days with price ≤ current price.
|
||||||
|
|
||||||
|
With up to `10^4` calls to `next`, and each call potentially scanning O(n) previous prices, this gives O(n^2) total time — far too slow.
|
||||||
|
|
||||||
|
Example: if prices are strictly increasing `[1, 2, 3, ..., 10000]`, each call scans all previous prices, resulting in 1 + 2 + 3 + ... + 10000 ≈ 50 million operations.
|
||||||
|
wrong_approach: "Store all prices and scan backward each time"
|
||||||
|
correct_approach: "Use monotonic stack to track only relevant prices"
|
||||||
|
|
||||||
|
- title: Forgetting to Include the Current Day
|
||||||
|
description: |
|
||||||
|
The span always includes the current day itself. Initialise `span = 1`, not `span = 0`.
|
||||||
|
|
||||||
|
If today's price is lower than all previous prices, the span should be `1` (just today), not `0`.
|
||||||
|
wrong_approach: "Initialise span = 0"
|
||||||
|
correct_approach: "Initialise span = 1 to include current day"
|
||||||
|
|
||||||
|
- title: Using Wrong Comparison Operator
|
||||||
|
description: |
|
||||||
|
The span includes days where price was **less than or equal** to today's price. Use `<=` when comparing.
|
||||||
|
|
||||||
|
If you use `<` instead, you'll miss days with equal prices and return incorrect spans.
|
||||||
|
|
||||||
|
Example: prices `[100, 80, 80]`. The span for the second `80` should be `2` (both 80s), not `1`.
|
||||||
|
wrong_approach: "Pop while stack top < current price"
|
||||||
|
correct_approach: "Pop while stack top <= current price"
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Monotonic stack pattern**: When you need to find the nearest larger/smaller element or count elements until a condition breaks, a monotonic stack often provides O(n) amortised time"
|
||||||
|
- "**Absorbing spans**: By storing accumulated spans with each stack entry, we avoid re-counting — each day is counted exactly once across all operations"
|
||||||
|
- "**Amortised analysis**: Each price is pushed once and popped at most once, so n operations cost O(n) total, giving O(1) amortised per call"
|
||||||
|
- "**Design pattern**: This problem combines data structure design with the monotonic stack pattern — a common interview combination"
|
||||||
|
|
||||||
|
time_complexity: "O(1) amortised per call to `next`. Each price is pushed and popped at most once across all operations, so n calls cost O(n) total."
|
||||||
|
space_complexity: "O(n) where n is the number of calls to `next`. In the worst case (strictly decreasing prices), all prices remain on the stack."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Monotonic Decreasing Stack
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
class StockSpanner:
|
||||||
|
def __init__(self):
|
||||||
|
# Stack stores (price, span) tuples in decreasing price order
|
||||||
|
self.stack = []
|
||||||
|
|
||||||
|
def next(self, price: int) -> int:
|
||||||
|
# Current day always counts as 1
|
||||||
|
span = 1
|
||||||
|
|
||||||
|
# Pop all prices <= current price and absorb their spans
|
||||||
|
while self.stack and self.stack[-1][0] <= price:
|
||||||
|
_, prev_span = self.stack.pop()
|
||||||
|
span += prev_span
|
||||||
|
|
||||||
|
# Push current price with its accumulated span
|
||||||
|
self.stack.append((price, span))
|
||||||
|
|
||||||
|
return span
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(1) amortised — Each price is pushed once and popped at most once.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n) — Stack can hold up to n prices in worst case.
|
||||||
|
|
||||||
|
The monotonic stack maintains prices in strictly decreasing order. When a new price arrives, we pop all smaller-or-equal prices and add their spans to the current span. This "absorbs" multiple days in one operation, avoiding repeated backward scans.
|
||||||
|
|
||||||
|
- approach_name: Brute Force (Store All Prices)
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
class StockSpanner:
|
||||||
|
def __init__(self):
|
||||||
|
# Store all prices seen so far
|
||||||
|
self.prices = []
|
||||||
|
|
||||||
|
def next(self, price: int) -> int:
|
||||||
|
self.prices.append(price)
|
||||||
|
span = 1
|
||||||
|
|
||||||
|
# Scan backward counting consecutive days <= current price
|
||||||
|
i = len(self.prices) - 2
|
||||||
|
while i >= 0 and self.prices[i] <= price:
|
||||||
|
span += 1
|
||||||
|
i -= 1
|
||||||
|
|
||||||
|
return span
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) per call in worst case — Must scan all previous prices.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n) — Stores all prices.
|
||||||
|
|
||||||
|
This approach stores every price and scans backward each time. While correct, it's O(n) per call, giving O(n^2) total for n calls. With `10^4` calls, this approach will be too slow. Included to illustrate why the monotonic stack optimisation is necessary.
|
||||||
237
backend/data/questions/open-the-lock.yaml
Normal file
237
backend/data/questions/open-the-lock.yaml
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
title: Open the Lock
|
||||||
|
slug: open-the-lock
|
||||||
|
difficulty: medium
|
||||||
|
leetcode_id: 752
|
||||||
|
leetcode_url: https://leetcode.com/problems/open-the-lock/
|
||||||
|
categories:
|
||||||
|
- strings
|
||||||
|
- hash-tables
|
||||||
|
- graphs
|
||||||
|
patterns:
|
||||||
|
- bfs
|
||||||
|
|
||||||
|
description: |
|
||||||
|
You have a lock in front of you with 4 circular wheels. Each wheel has 10 slots: `'0', '1', '2', '3', '4', '5', '6', '7', '8', '9'`. The wheels can rotate freely and wrap around: for example we can turn `'9'` to be `'0'`, or `'0'` to be `'9'`. Each move consists of turning **one wheel one slot**.
|
||||||
|
|
||||||
|
The lock initially starts at `'0000'`, a string representing the state of the 4 wheels.
|
||||||
|
|
||||||
|
You are given a list of `deadends` dead ends, meaning if the lock displays any of these codes, the wheels of the lock will stop turning and you will be unable to open it.
|
||||||
|
|
||||||
|
Given a `target` representing the value of the wheels that will unlock the lock, return *the minimum number of turns required to open the lock*, or `-1` if it is impossible.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `1 <= deadends.length <= 500`
|
||||||
|
- `deadends[i].length == 4`
|
||||||
|
- `target.length == 4`
|
||||||
|
- `target` will not be in the list `deadends`
|
||||||
|
- `target` and `deadends[i]` consist of digits only
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: 'deadends = ["0201","0101","0102","1212","2002"], target = "0202"'
|
||||||
|
output: "6"
|
||||||
|
explanation: 'A sequence of valid moves would be "0000" → "1000" → "1100" → "1200" → "1201" → "1202" → "0202". Note that "0000" → "0001" → "0002" → "0102" → "0202" would be invalid because the lock becomes stuck at dead end "0102".'
|
||||||
|
- input: 'deadends = ["8888"], target = "0009"'
|
||||||
|
output: "1"
|
||||||
|
explanation: 'We can turn the last wheel in reverse to move from "0000" → "0009".'
|
||||||
|
- input: 'deadends = ["8887","8889","8878","8898","8788","8988","7888","9888"], target = "8888"'
|
||||||
|
output: "-1"
|
||||||
|
explanation: "We cannot reach the target without getting stuck. All paths to 8888 are blocked by deadends."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Imagine standing in front of a combination lock with 4 wheels, each showing a digit from 0-9. You start at `"0000"` and need to reach the target code, but certain combinations will jam the lock forever.
|
||||||
|
|
||||||
|
Think of it like this: each 4-digit code is a **node** in a graph, and from any code, you can reach exactly **8 neighbouring codes** — one for each wheel turned up or down. Some nodes (deadends) are "blocked" and cannot be visited. Your goal is to find the **shortest path** from `"0000"` to the target.
|
||||||
|
|
||||||
|
When you need the shortest path in an unweighted graph where each edge has equal cost (one turn), **BFS is the optimal choice**. BFS explores nodes level by level, guaranteeing that the first time we reach the target, we've found the minimum number of moves.
|
||||||
|
|
||||||
|
The key insight is recognising this as a graph traversal problem in disguise. The state space is all 10,000 possible codes (`"0000"` to `"9999"`), and we're doing a shortest-path search with obstacles.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using **BFS on the State Space Graph**:
|
||||||
|
|
||||||
|
**Step 1: Handle edge cases**
|
||||||
|
|
||||||
|
- If `"0000"` is a deadend, return `-1` immediately — we can't even start
|
||||||
|
- If target is `"0000"`, return `0` — we're already there
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Initialise BFS structures**
|
||||||
|
|
||||||
|
- `visited`: A set containing all deadends (states we cannot enter) plus states we've already explored
|
||||||
|
- `queue`: Start with `("0000", 0)` where `0` is the number of turns taken
|
||||||
|
- Add `"0000"` to visited
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: BFS traversal**
|
||||||
|
|
||||||
|
- Dequeue the current state and its turn count
|
||||||
|
- If it equals the target, return the turn count
|
||||||
|
- Generate all 8 neighbours (turn each of 4 wheels up or down by 1)
|
||||||
|
- For each neighbour not in visited, add it to visited and enqueue with `turns + 1`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 4: Generate neighbours**
|
||||||
|
|
||||||
|
- For each wheel position (0 to 3):
|
||||||
|
- Turn up: digit becomes `(d + 1) % 10` (wraps 9 → 0)
|
||||||
|
- Turn down: digit becomes `(d - 1) % 10` (wraps 0 → 9)
|
||||||
|
- This produces exactly 8 neighbours per state
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 5: Return result**
|
||||||
|
|
||||||
|
- If BFS completes without finding target, return `-1`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
This works because BFS explores states in order of increasing distance from the start. The first time we reach the target, we've found the shortest path.
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Using DFS Instead of BFS
|
||||||
|
description: |
|
||||||
|
DFS will find *a* path to the target, but not necessarily the *shortest* path. Since we need the minimum number of turns, we must use BFS.
|
||||||
|
|
||||||
|
BFS guarantees that when we first reach a node, we've taken the fewest steps to get there. DFS would need to explore all paths and track the minimum, which is much less efficient.
|
||||||
|
wrong_approach: "DFS to find any path"
|
||||||
|
correct_approach: "BFS to find shortest path"
|
||||||
|
|
||||||
|
- title: Forgetting to Check if Start is a Deadend
|
||||||
|
description: |
|
||||||
|
If `"0000"` itself is in the deadends list, we can never begin. Always check this before starting BFS.
|
||||||
|
|
||||||
|
Similarly, if the target is `"0000"`, return `0` immediately — no moves needed.
|
||||||
|
wrong_approach: "Starting BFS without checking initial state"
|
||||||
|
correct_approach: "Check if '0000' in deadends before BFS"
|
||||||
|
|
||||||
|
- title: Adding to Visited Too Late
|
||||||
|
description: |
|
||||||
|
A common bug is marking states as visited when **dequeuing** rather than when **enqueuing**. This causes the same state to be added to the queue multiple times from different paths, wasting time and memory.
|
||||||
|
|
||||||
|
For example, both `"1000"` and `"0100"` can reach `"1100"`. If we mark visited on dequeue, both will add `"1100"` to the queue.
|
||||||
|
wrong_approach: "Mark visited when popping from queue"
|
||||||
|
correct_approach: "Mark visited immediately when adding to queue"
|
||||||
|
|
||||||
|
- title: Incorrect Wrap-Around Logic
|
||||||
|
description: |
|
||||||
|
The wheels wrap around: `9 + 1 = 0` and `0 - 1 = 9`. Using `(d + 1) % 10` handles the forward wrap correctly, but `(d - 1) % 10` in Python needs care.
|
||||||
|
|
||||||
|
In Python, `(-1) % 10 = 9`, so `(d - 1) % 10` works correctly. In some languages, you may need `(d + 9) % 10` instead.
|
||||||
|
wrong_approach: "d - 1 without handling negative (in some languages)"
|
||||||
|
correct_approach: "(d - 1) % 10 or (d + 9) % 10"
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Implicit graph recognition**: Many problems involve state spaces that form graphs — codes, configurations, game states"
|
||||||
|
- "**BFS for shortest path in unweighted graphs**: When all edges have equal weight, BFS finds the optimal path"
|
||||||
|
- "**Deadends as blocked nodes**: Preloading obstacles into the visited set is a clean way to handle forbidden states"
|
||||||
|
- "**State generation**: Being able to enumerate all neighbours of a state is key to graph-based solutions"
|
||||||
|
|
||||||
|
time_complexity: "O(10<sup>4</sup> × 4) = O(1). At most 10,000 states, each generating 8 neighbours. Effectively O(10<sup>4</sup>) which is constant."
|
||||||
|
space_complexity: "O(10<sup>4</sup>) = O(1). The visited set and queue can hold at most all 10,000 possible codes."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: BFS
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
def open_lock(deadends: list[str], target: str) -> int:
|
||||||
|
# Convert deadends to set for O(1) lookup
|
||||||
|
dead = set(deadends)
|
||||||
|
|
||||||
|
# Edge case: can't even start
|
||||||
|
if "0000" in dead:
|
||||||
|
return -1
|
||||||
|
|
||||||
|
# Edge case: already at target
|
||||||
|
if target == "0000":
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# BFS setup
|
||||||
|
queue = deque([("0000", 0)]) # (state, turns)
|
||||||
|
visited = {"0000"}
|
||||||
|
|
||||||
|
while queue:
|
||||||
|
state, turns = queue.popleft()
|
||||||
|
|
||||||
|
# Generate all 8 neighbours (4 wheels × 2 directions)
|
||||||
|
for i in range(4):
|
||||||
|
digit = int(state[i])
|
||||||
|
|
||||||
|
# Turn wheel up (+1) and down (-1)
|
||||||
|
for delta in [1, -1]:
|
||||||
|
new_digit = (digit + delta) % 10
|
||||||
|
# Build new state string
|
||||||
|
new_state = state[:i] + str(new_digit) + state[i+1:]
|
||||||
|
|
||||||
|
# Check if we found target
|
||||||
|
if new_state == target:
|
||||||
|
return turns + 1
|
||||||
|
|
||||||
|
# Add unvisited, non-dead states to queue
|
||||||
|
if new_state not in visited and new_state not in dead:
|
||||||
|
visited.add(new_state)
|
||||||
|
queue.append((new_state, turns + 1))
|
||||||
|
|
||||||
|
# Target unreachable
|
||||||
|
return -1
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(10<sup>4</sup>) — We visit each of the 10,000 possible states at most once. Generating 8 neighbours per state is O(1).
|
||||||
|
|
||||||
|
**Space Complexity:** O(10<sup>4</sup>) — The visited set and queue together hold at most all possible states.
|
||||||
|
|
||||||
|
BFS explores states level by level, where each level represents one more turn. When we first reach the target, we've found the minimum number of turns. The deadends are implicitly handled by never adding them to the queue.
|
||||||
|
|
||||||
|
- approach_name: Bidirectional BFS
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def open_lock(deadends: list[str], target: str) -> int:
|
||||||
|
dead = set(deadends)
|
||||||
|
|
||||||
|
if "0000" in dead:
|
||||||
|
return -1
|
||||||
|
if target == "0000":
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Two frontiers expanding toward each other
|
||||||
|
front = {"0000"}
|
||||||
|
back = {target}
|
||||||
|
visited = {"0000", target}
|
||||||
|
turns = 0
|
||||||
|
|
||||||
|
while front and back:
|
||||||
|
# Always expand the smaller frontier
|
||||||
|
if len(front) > len(back):
|
||||||
|
front, back = back, front
|
||||||
|
|
||||||
|
next_front = set()
|
||||||
|
turns += 1
|
||||||
|
|
||||||
|
for state in front:
|
||||||
|
for i in range(4):
|
||||||
|
digit = int(state[i])
|
||||||
|
for delta in [1, -1]:
|
||||||
|
new_digit = (digit + delta) % 10
|
||||||
|
new_state = state[:i] + str(new_digit) + state[i+1:]
|
||||||
|
|
||||||
|
# Frontiers meet — found shortest path
|
||||||
|
if new_state in back:
|
||||||
|
return turns
|
||||||
|
|
||||||
|
if new_state not in visited and new_state not in dead:
|
||||||
|
visited.add(new_state)
|
||||||
|
next_front.add(new_state)
|
||||||
|
|
||||||
|
front = next_front
|
||||||
|
|
||||||
|
return -1
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(10<sup>4</sup>) — Same worst case, but often faster in practice due to reduced search space.
|
||||||
|
|
||||||
|
**Space Complexity:** O(10<sup>4</sup>) — Same as standard BFS.
|
||||||
|
|
||||||
|
Bidirectional BFS searches from both start and target simultaneously. When the two frontiers meet, we've found the shortest path. By always expanding the smaller frontier, we minimise the search space. This optimisation is especially effective when the branching factor is high.
|
||||||
278
backend/data/questions/pacific-atlantic-water-flow.yaml
Normal file
278
backend/data/questions/pacific-atlantic-water-flow.yaml
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
title: Pacific Atlantic Water Flow
|
||||||
|
slug: pacific-atlantic-water-flow
|
||||||
|
difficulty: medium
|
||||||
|
leetcode_id: 417
|
||||||
|
leetcode_url: https://leetcode.com/problems/pacific-atlantic-water-flow/
|
||||||
|
categories:
|
||||||
|
- graphs
|
||||||
|
- arrays
|
||||||
|
patterns:
|
||||||
|
- dfs
|
||||||
|
- matrix-traversal
|
||||||
|
|
||||||
|
description: |
|
||||||
|
There is an `m x n` rectangular island that borders both the **Pacific Ocean** and **Atlantic Ocean**. The **Pacific Ocean** touches the island's left and top edges, and the **Atlantic Ocean** touches the island's right and bottom edges.
|
||||||
|
|
||||||
|
The island is partitioned into a grid of square cells. You are given an `m x n` integer matrix `heights` where `heights[r][c]` represents the **height above sea level** of the cell at coordinate `(r, c)`.
|
||||||
|
|
||||||
|
The island receives a lot of rain, and the rain water can flow to neighbouring cells directly north, south, east, and west if the neighbouring cell's height is **less than or equal to** the current cell's height. Water can flow from any cell adjacent to an ocean into the ocean.
|
||||||
|
|
||||||
|
Return *a **2D list** of grid coordinates* `result` *where* `result[i] = [r_i, c_i]` *denotes that rain water can flow from cell* `(r_i, c_i)` *to **both** the Pacific and Atlantic oceans*.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `m == heights.length`
|
||||||
|
- `n == heights[r].length`
|
||||||
|
- `1 <= m, n <= 200`
|
||||||
|
- `0 <= heights[r][c] <= 10^5`
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: "heights = [[1,2,2,3,5],[3,2,3,4,4],[2,4,5,3,1],[6,7,1,4,5],[5,1,1,2,4]]"
|
||||||
|
output: "[[0,4],[1,3],[1,4],[2,2],[3,0],[3,1],[4,0]]"
|
||||||
|
explanation: "These cells can reach both oceans. For example, [2,2] flows to Pacific via [2,2] -> [1,2] -> [0,2] and to Atlantic via [2,2] -> [2,3] -> [2,4]."
|
||||||
|
- input: "heights = [[1]]"
|
||||||
|
output: "[[0,0]]"
|
||||||
|
explanation: "The single cell borders both oceans, so water flows to both."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Imagine standing on the island during heavy rain. Water flows downhill (or stays level), eventually reaching an ocean. The question asks: from which cells can water reach *both* oceans?
|
||||||
|
|
||||||
|
The naive approach would be to start from every cell and try to find paths to both oceans — but this is inefficient. Instead, think about the problem **in reverse**: rather than asking "where can water from this cell go?", ask "which cells can water reach this ocean from?"
|
||||||
|
|
||||||
|
**The key insight**: Start from the ocean boundaries and flow *uphill* (to cells with equal or greater height). Any cell reachable by "reverse flow" from the Pacific can eventually drain into the Pacific. Similarly for the Atlantic.
|
||||||
|
|
||||||
|
Think of it like this: imagine the ocean water rising and flooding cells it can reach (only moving to equal or higher ground). After flooding from both oceans, the cells that got flooded by *both* are our answer.
|
||||||
|
|
||||||
|
By running DFS from all Pacific-bordering cells and separately from all Atlantic-bordering cells, we get two sets of reachable cells. The intersection of these sets is our answer.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using **Reverse DFS from Ocean Boundaries**:
|
||||||
|
|
||||||
|
**Step 1: Initialise tracking sets**
|
||||||
|
|
||||||
|
- `pacific_reachable`: A set of cells that can reach the Pacific Ocean
|
||||||
|
- `atlantic_reachable`: A set of cells that can reach the Atlantic Ocean
|
||||||
|
- The Pacific borders the top row and left column
|
||||||
|
- The Atlantic borders the bottom row and right column
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Define DFS helper function**
|
||||||
|
|
||||||
|
- The DFS explores cells by moving to neighbours with **equal or greater** height (reverse flow)
|
||||||
|
- Mark cells as visited by adding them to the respective ocean's reachable set
|
||||||
|
- Explore all four directions: up, down, left, right
|
||||||
|
- Skip cells that are out of bounds, already visited, or have lower height
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Run DFS from Pacific borders**
|
||||||
|
|
||||||
|
- Start DFS from every cell in the top row (row 0)
|
||||||
|
- Start DFS from every cell in the left column (column 0)
|
||||||
|
- All cells visited get added to `pacific_reachable`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 4: Run DFS from Atlantic borders**
|
||||||
|
|
||||||
|
- Start DFS from every cell in the bottom row (row m-1)
|
||||||
|
- Start DFS from every cell in the right column (column n-1)
|
||||||
|
- All cells visited get added to `atlantic_reachable`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 5: Find the intersection**
|
||||||
|
|
||||||
|
- Return all cells that appear in *both* `pacific_reachable` and `atlantic_reachable`
|
||||||
|
- These are the cells where water can flow to both oceans
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Forward Flow is Inefficient
|
||||||
|
description: |
|
||||||
|
Starting DFS from every cell and checking if it can reach both oceans leads to redundant work. With a 200x200 grid (40,000 cells), each potentially doing O(m*n) work, this approaches O((m*n)^2) — far too slow.
|
||||||
|
|
||||||
|
The reverse flow approach does at most O(m*n) work for each ocean, giving O(m*n) total.
|
||||||
|
wrong_approach: "DFS from every cell checking reachability to both oceans"
|
||||||
|
correct_approach: "Reverse DFS from ocean boundaries, then intersect"
|
||||||
|
|
||||||
|
- title: Incorrect Flow Direction
|
||||||
|
description: |
|
||||||
|
When doing reverse DFS from the oceans, water flows *uphill* (to equal or greater height). A common mistake is continuing to use the normal downhill condition.
|
||||||
|
|
||||||
|
**Normal flow**: water goes to neighbour if `neighbour_height <= current_height`
|
||||||
|
|
||||||
|
**Reverse flow**: water came from neighbour if `neighbour_height >= current_height`
|
||||||
|
|
||||||
|
Mixing these up gives wrong results.
|
||||||
|
wrong_approach: "Check if neighbour height <= current height during reverse DFS"
|
||||||
|
correct_approach: "Check if neighbour height >= current height during reverse DFS"
|
||||||
|
|
||||||
|
- title: Forgetting Corner Cells
|
||||||
|
description: |
|
||||||
|
The corner cells belong to both ocean boundaries:
|
||||||
|
- Top-left `(0, 0)`: Pacific (top row AND left column)
|
||||||
|
- Top-right `(0, n-1)`: Pacific (top) and Atlantic (right)
|
||||||
|
- Bottom-left `(m-1, 0)`: Pacific (left) and Atlantic (bottom)
|
||||||
|
- Bottom-right `(m-1, n-1)`: Atlantic (bottom AND right)
|
||||||
|
|
||||||
|
Using sets for visited cells naturally handles the overlap — starting DFS from a corner twice doesn't cause issues.
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Reverse the problem**: Instead of asking 'where can this cell reach?', ask 'which cells can reach this destination?' This often simplifies graph reachability problems."
|
||||||
|
- "**Multi-source BFS/DFS**: When checking reachability to a boundary, start from all boundary cells simultaneously rather than from every interior cell."
|
||||||
|
- "**Set intersection**: When finding cells satisfying multiple conditions, compute each condition separately and intersect the results."
|
||||||
|
- "**Matrix traversal pattern**: This problem combines DFS with the 4-directional movement pattern common in grid problems."
|
||||||
|
|
||||||
|
time_complexity: "O(m * n). Each cell is visited at most twice (once for Pacific DFS, once for Atlantic DFS), and each visit does O(1) work plus recursive calls to unvisited neighbours."
|
||||||
|
space_complexity: "O(m * n). We store two sets that can each contain up to m*n cells, plus the recursion stack which can be O(m * n) deep in the worst case."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Reverse DFS
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def pacific_atlantic(heights: list[list[int]]) -> list[list[int]]:
|
||||||
|
if not heights or not heights[0]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
m, n = len(heights), len(heights[0])
|
||||||
|
pacific_reachable = set()
|
||||||
|
atlantic_reachable = set()
|
||||||
|
|
||||||
|
def dfs(row: int, col: int, reachable: set, prev_height: int) -> None:
|
||||||
|
# Skip if out of bounds, already visited, or can't flow uphill
|
||||||
|
if (row < 0 or row >= m or col < 0 or col >= n or
|
||||||
|
(row, col) in reachable or heights[row][col] < prev_height):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Mark this cell as reachable from this ocean
|
||||||
|
reachable.add((row, col))
|
||||||
|
|
||||||
|
# Explore all four directions (reverse flow: go to >= height)
|
||||||
|
current_height = heights[row][col]
|
||||||
|
dfs(row + 1, col, reachable, current_height) # down
|
||||||
|
dfs(row - 1, col, reachable, current_height) # up
|
||||||
|
dfs(row, col + 1, reachable, current_height) # right
|
||||||
|
dfs(row, col - 1, reachable, current_height) # left
|
||||||
|
|
||||||
|
# Start DFS from Pacific borders (top row and left column)
|
||||||
|
for col in range(n):
|
||||||
|
dfs(0, col, pacific_reachable, heights[0][col])
|
||||||
|
for row in range(m):
|
||||||
|
dfs(row, 0, pacific_reachable, heights[row][0])
|
||||||
|
|
||||||
|
# Start DFS from Atlantic borders (bottom row and right column)
|
||||||
|
for col in range(n):
|
||||||
|
dfs(m - 1, col, atlantic_reachable, heights[m - 1][col])
|
||||||
|
for row in range(m):
|
||||||
|
dfs(row, n - 1, atlantic_reachable, heights[row][n - 1])
|
||||||
|
|
||||||
|
# Return cells reachable from both oceans
|
||||||
|
return [[r, c] for r, c in pacific_reachable & atlantic_reachable]
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(m * n) — Each cell visited at most twice.
|
||||||
|
|
||||||
|
**Space Complexity:** O(m * n) — For the two reachable sets and recursion stack.
|
||||||
|
|
||||||
|
We reverse the problem: instead of checking where water from each cell can go, we check which cells can reach each ocean by flowing "uphill" from the ocean boundaries. The intersection of both reachable sets gives our answer.
|
||||||
|
|
||||||
|
- approach_name: Reverse BFS
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
def pacific_atlantic(heights: list[list[int]]) -> list[list[int]]:
|
||||||
|
if not heights or not heights[0]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
m, n = len(heights), len(heights[0])
|
||||||
|
directions = [(0, 1), (0, -1), (1, 0), (-1, 0)]
|
||||||
|
|
||||||
|
def bfs(starts: list[tuple[int, int]]) -> set[tuple[int, int]]:
|
||||||
|
reachable = set(starts)
|
||||||
|
queue = deque(starts)
|
||||||
|
|
||||||
|
while queue:
|
||||||
|
row, col = queue.popleft()
|
||||||
|
current_height = heights[row][col]
|
||||||
|
|
||||||
|
# Check all four neighbours
|
||||||
|
for dr, dc in directions:
|
||||||
|
new_row, new_col = row + dr, col + dc
|
||||||
|
|
||||||
|
# Skip if out of bounds, visited, or lower height
|
||||||
|
if (0 <= new_row < m and 0 <= new_col < n and
|
||||||
|
(new_row, new_col) not in reachable and
|
||||||
|
heights[new_row][new_col] >= current_height):
|
||||||
|
|
||||||
|
reachable.add((new_row, new_col))
|
||||||
|
queue.append((new_row, new_col))
|
||||||
|
|
||||||
|
return reachable
|
||||||
|
|
||||||
|
# Pacific borders: top row + left column
|
||||||
|
pacific_starts = [(0, c) for c in range(n)] + [(r, 0) for r in range(1, m)]
|
||||||
|
|
||||||
|
# Atlantic borders: bottom row + right column
|
||||||
|
atlantic_starts = [(m - 1, c) for c in range(n)] + [(r, n - 1) for r in range(m - 1)]
|
||||||
|
|
||||||
|
pacific_reachable = bfs(pacific_starts)
|
||||||
|
atlantic_reachable = bfs(atlantic_starts)
|
||||||
|
|
||||||
|
return [[r, c] for r, c in pacific_reachable & atlantic_reachable]
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(m * n) — Each cell visited at most twice.
|
||||||
|
|
||||||
|
**Space Complexity:** O(m * n) — For the reachable sets and BFS queue.
|
||||||
|
|
||||||
|
This is the iterative BFS version of the same approach. Starting from all ocean boundary cells simultaneously, BFS explores cells level by level. Both approaches have the same complexity; BFS avoids potential stack overflow for very large grids.
|
||||||
|
|
||||||
|
- approach_name: Brute Force DFS
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def pacific_atlantic(heights: list[list[int]]) -> list[list[int]]:
|
||||||
|
if not heights or not heights[0]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
m, n = len(heights), len(heights[0])
|
||||||
|
result = []
|
||||||
|
|
||||||
|
def can_reach_ocean(start_row: int, start_col: int, ocean: str) -> bool:
|
||||||
|
visited = set()
|
||||||
|
|
||||||
|
def dfs(row: int, col: int, prev_height: int) -> bool:
|
||||||
|
# Check if we've reached the target ocean
|
||||||
|
if ocean == "pacific" and (row < 0 or col < 0):
|
||||||
|
return True
|
||||||
|
if ocean == "atlantic" and (row >= m or col >= n):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Skip invalid cells
|
||||||
|
if (row < 0 or row >= m or col < 0 or col >= n or
|
||||||
|
(row, col) in visited or heights[row][col] > prev_height):
|
||||||
|
return False
|
||||||
|
|
||||||
|
visited.add((row, col))
|
||||||
|
current = heights[row][col]
|
||||||
|
|
||||||
|
# Try all four directions
|
||||||
|
return (dfs(row - 1, col, current) or # up
|
||||||
|
dfs(row + 1, col, current) or # down
|
||||||
|
dfs(row, col - 1, current) or # left
|
||||||
|
dfs(row, col + 1, current)) # right
|
||||||
|
|
||||||
|
return dfs(start_row, start_col, heights[start_row][start_col])
|
||||||
|
|
||||||
|
# Check every cell
|
||||||
|
for row in range(m):
|
||||||
|
for col in range(n):
|
||||||
|
if can_reach_ocean(row, col, "pacific") and can_reach_ocean(row, col, "atlantic"):
|
||||||
|
result.append([row, col])
|
||||||
|
|
||||||
|
return result
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O((m * n)^2) — For each of m*n cells, we may visit up to m*n cells.
|
||||||
|
|
||||||
|
**Space Complexity:** O(m * n) — For the visited set and recursion stack per DFS.
|
||||||
|
|
||||||
|
This naive approach checks each cell individually for reachability to both oceans. It's correct but inefficient due to redundant exploration. For a 200x200 grid, this could mean up to 1.6 billion operations. Included to illustrate why the reverse-flow approach is necessary.
|
||||||
194
backend/data/questions/palindrome-number.yaml
Normal file
194
backend/data/questions/palindrome-number.yaml
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
title: Palindrome Number
|
||||||
|
slug: palindrome-number
|
||||||
|
difficulty: easy
|
||||||
|
leetcode_id: 9
|
||||||
|
leetcode_url: https://leetcode.com/problems/palindrome-number/
|
||||||
|
categories:
|
||||||
|
- math
|
||||||
|
patterns:
|
||||||
|
- two-pointers
|
||||||
|
|
||||||
|
function_signature: "def is_palindrome(x: int) -> bool:"
|
||||||
|
|
||||||
|
test_cases:
|
||||||
|
visible:
|
||||||
|
- input: { x: 121 }
|
||||||
|
expected: true
|
||||||
|
- input: { x: -121 }
|
||||||
|
expected: false
|
||||||
|
- input: { x: 10 }
|
||||||
|
expected: false
|
||||||
|
hidden:
|
||||||
|
- input: { x: 0 }
|
||||||
|
expected: true
|
||||||
|
- input: { x: 12321 }
|
||||||
|
expected: true
|
||||||
|
- input: { x: 1234 }
|
||||||
|
expected: false
|
||||||
|
- input: { x: 11 }
|
||||||
|
expected: true
|
||||||
|
- input: { x: -1 }
|
||||||
|
expected: false
|
||||||
|
|
||||||
|
description: |
|
||||||
|
Given an integer `x`, return `true` if `x` is a **palindrome**, and `false` otherwise.
|
||||||
|
|
||||||
|
A **palindrome** is a number that reads the same backward as forward. For example, `121` is a palindrome while `123` is not.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `-2^31 <= x <= 2^31 - 1`
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: "x = 121"
|
||||||
|
output: "true"
|
||||||
|
explanation: "121 reads as 121 from left to right and from right to left."
|
||||||
|
- input: "x = -121"
|
||||||
|
output: "false"
|
||||||
|
explanation: "From left to right, it reads -121. From right to left, it becomes 121-. Therefore it is not a palindrome."
|
||||||
|
- input: "x = 10"
|
||||||
|
output: "false"
|
||||||
|
explanation: "Reads 01 from right to left. Therefore it is not a palindrome."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Think of a palindrome like a mirror: the left half reflects perfectly onto the right half.
|
||||||
|
|
||||||
|
For a number like `12321`, if you cover the middle digit and compare the left side (`12`) with the reversed right side (`12`), they match. The key insight is that we don't need to reverse the entire number — we only need to reverse **half** of it and compare.
|
||||||
|
|
||||||
|
Why reverse only half? Two reasons:
|
||||||
|
1. **Efficiency**: We avoid potential integer overflow that could occur when reversing large numbers
|
||||||
|
2. **Elegance**: Once we've processed half the digits, we have enough information to determine if it's a palindrome
|
||||||
|
|
||||||
|
The clever trick is knowing when we've reached the halfway point: when the reversed half becomes greater than or equal to the remaining original number, we've crossed the middle.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this by **reversing the second half** of the number and comparing it to the first half:
|
||||||
|
|
||||||
|
**Step 1: Handle edge cases**
|
||||||
|
|
||||||
|
- Negative numbers are never palindromes (the minus sign doesn't mirror)
|
||||||
|
- Numbers ending in `0` (except `0` itself) are never palindromes — they would need a leading zero
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Reverse the second half digit by digit**
|
||||||
|
|
||||||
|
- Extract the last digit using `x % 10`
|
||||||
|
- Build the reversed number: `reversed = reversed * 10 + last_digit`
|
||||||
|
- Remove the last digit from original: `x = x // 10`
|
||||||
|
- Continue until `reversed >= x` (we've reached or passed the middle)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Compare the two halves**
|
||||||
|
|
||||||
|
- For even-length numbers: `x == reversed` (e.g., `1221` → `x=12`, `reversed=12`)
|
||||||
|
- For odd-length numbers: `x == reversed // 10` (e.g., `12321` → `x=12`, `reversed=123`, ignore middle digit)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
This approach elegantly handles both even and odd length numbers without special cases.
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Converting to String
|
||||||
|
description: |
|
||||||
|
The most intuitive approach is converting the number to a string and checking if it equals its reverse:
|
||||||
|
|
||||||
|
```python
|
||||||
|
return str(x) == str(x)[::-1]
|
||||||
|
```
|
||||||
|
|
||||||
|
While this works and is O(n), it uses O(n) extra space for the string and involves type conversion overhead. The follow-up challenge asks us to solve it without string conversion.
|
||||||
|
wrong_approach: "String conversion and reversal"
|
||||||
|
correct_approach: "Reverse half the number mathematically"
|
||||||
|
|
||||||
|
- title: Integer Overflow When Reversing
|
||||||
|
description: |
|
||||||
|
If you try to reverse the entire number, you might overflow for values near `2^31`. For example, reversing `2147483647` would give `7463847412`, which exceeds the 32-bit integer limit.
|
||||||
|
|
||||||
|
By only reversing half the number, we guarantee the reversed portion stays within bounds since it's at most half the size of the original.
|
||||||
|
wrong_approach: "Reverse the entire number"
|
||||||
|
correct_approach: "Reverse only half to avoid overflow"
|
||||||
|
|
||||||
|
- title: Forgetting Numbers Ending in Zero
|
||||||
|
description: |
|
||||||
|
Numbers like `10`, `100`, `1230` end in zero. For these to be palindromes, they'd need to start with zero — but no positive integer starts with zero.
|
||||||
|
|
||||||
|
The only exception is `0` itself, which is a palindrome. Check for `x % 10 == 0 and x != 0` as an early exit.
|
||||||
|
wrong_approach: "Missing the trailing zero check"
|
||||||
|
correct_approach: "Return false early for non-zero numbers ending in 0"
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Half-reversal technique**: When checking symmetry, you often only need to process half the data"
|
||||||
|
- "**Overflow prevention**: Reversing only half of a number keeps values safely within integer bounds"
|
||||||
|
- "**Edge cases matter**: Negative numbers and trailing zeros need explicit handling"
|
||||||
|
- "**Math over strings**: Many digit-manipulation problems have elegant mathematical solutions that avoid string conversion overhead"
|
||||||
|
|
||||||
|
time_complexity: "O(log n). We process half the digits, and a number has log₁₀(n) digits."
|
||||||
|
space_complexity: "O(1). We only use a fixed number of variables regardless of input size."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Reverse Half Number
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def is_palindrome(x: int) -> bool:
|
||||||
|
# Negative numbers and numbers ending in 0 (except 0 itself) aren't palindromes
|
||||||
|
if x < 0 or (x % 10 == 0 and x != 0):
|
||||||
|
return False
|
||||||
|
|
||||||
|
reversed_half = 0
|
||||||
|
|
||||||
|
# Build reversed half until it's >= remaining number
|
||||||
|
while x > reversed_half:
|
||||||
|
# Extract last digit and add to reversed half
|
||||||
|
reversed_half = reversed_half * 10 + x % 10
|
||||||
|
# Remove last digit from original
|
||||||
|
x //= 10
|
||||||
|
|
||||||
|
# Even length: x == reversed_half (e.g., 1221)
|
||||||
|
# Odd length: x == reversed_half // 10 (e.g., 12321, middle digit in reversed_half)
|
||||||
|
return x == reversed_half or x == reversed_half // 10
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(log n) — We process roughly half the digits.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — Only two integer variables used.
|
||||||
|
|
||||||
|
We reverse only the second half of the number and compare. The loop terminates when the reversed portion is at least as large as the remaining original, indicating we've reached the middle. The two conditions in the return handle both even-length and odd-length palindromes.
|
||||||
|
|
||||||
|
- approach_name: String Conversion
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def is_palindrome(x: int) -> bool:
|
||||||
|
# Convert to string and compare with reverse
|
||||||
|
s = str(x)
|
||||||
|
return s == s[::-1]
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — String creation and reversal each take O(n) where n is the number of digits.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n) — The string representation requires space proportional to the number of digits.
|
||||||
|
|
||||||
|
This is the most straightforward approach: convert to string and check if it equals its reverse. While simple and readable, it uses extra space and doesn't meet the follow-up challenge of avoiding string conversion.
|
||||||
|
|
||||||
|
- approach_name: Full Number Reversal
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def is_palindrome(x: int) -> bool:
|
||||||
|
# Negative numbers are not palindromes
|
||||||
|
if x < 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
original = x
|
||||||
|
reversed_num = 0
|
||||||
|
|
||||||
|
# Reverse the entire number
|
||||||
|
while x > 0:
|
||||||
|
reversed_num = reversed_num * 10 + x % 10
|
||||||
|
x //= 10
|
||||||
|
|
||||||
|
return original == reversed_num
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(log n) — We process all digits once.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — Only integer variables used.
|
||||||
|
|
||||||
|
This approach reverses the entire number and compares with the original. While it works for most cases, it can cause integer overflow for very large numbers near the 32-bit limit. The half-reversal approach is preferred as it avoids this risk.
|
||||||
200
backend/data/questions/palindrome-partitioning.yaml
Normal file
200
backend/data/questions/palindrome-partitioning.yaml
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
title: Palindrome Partitioning
|
||||||
|
slug: palindrome-partitioning
|
||||||
|
difficulty: medium
|
||||||
|
leetcode_id: 131
|
||||||
|
leetcode_url: https://leetcode.com/problems/palindrome-partitioning/
|
||||||
|
categories:
|
||||||
|
- strings
|
||||||
|
- dynamic-programming
|
||||||
|
- recursion
|
||||||
|
patterns:
|
||||||
|
- backtracking
|
||||||
|
- dynamic-programming
|
||||||
|
|
||||||
|
description: |
|
||||||
|
Given a string `s`, partition `s` such that every substring of the partition is a **palindrome**.
|
||||||
|
|
||||||
|
Return *all possible palindrome partitioning of* `s`.
|
||||||
|
|
||||||
|
A **palindrome** is a string that reads the same forward and backward.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `1 <= s.length <= 16`
|
||||||
|
- `s` contains only lowercase English letters
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: 's = "aab"'
|
||||||
|
output: '[["a","a","b"],["aa","b"]]'
|
||||||
|
explanation: "There are two valid partitions: split into individual characters ['a','a','b'], or combine the first two characters into the palindrome 'aa' plus 'b'."
|
||||||
|
- input: 's = "a"'
|
||||||
|
output: '[["a"]]'
|
||||||
|
explanation: "A single character is always a palindrome, so the only partition is the string itself."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Imagine you're trying to cut a string into pieces where each piece reads the same forwards and backwards. At each position, you have a choice: where should the next cut be?
|
||||||
|
|
||||||
|
Think of it like standing at the start of the string and asking: "What's the longest palindrome I can take starting from here?" But since we need *all* valid partitions, we can't just be greedy — we need to explore every valid cut point.
|
||||||
|
|
||||||
|
This is a classic **backtracking** problem. At each position `i`, we try extending a substring from `i` to every position `j >= i`. If that substring is a palindrome, we "take it" as part of our current partition and recursively solve for the remainder of the string.
|
||||||
|
|
||||||
|
The key insight is that we're building a **decision tree**: at each node, we decide how much of the remaining string to consume as the next palindrome. Every path from root to leaf represents one valid partitioning.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using **Backtracking with Palindrome Checking**:
|
||||||
|
|
||||||
|
**Step 1: Set up the recursive structure**
|
||||||
|
|
||||||
|
- Create a result list to store all valid partitions
|
||||||
|
- Create a path list to track the current partition being built
|
||||||
|
- Start recursion from index `0`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Define the base case**
|
||||||
|
|
||||||
|
- When our starting index equals the string length, we've successfully partitioned the entire string
|
||||||
|
- Add a copy of the current path to our results
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Try all possible cuts at each position**
|
||||||
|
|
||||||
|
- For each ending position `end` from `start` to `len(s)-1`:
|
||||||
|
- Extract the substring `s[start:end+1]`
|
||||||
|
- Check if it's a palindrome
|
||||||
|
- If yes: add it to path, recurse with `end+1` as new start, then backtrack by removing it
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 4: Palindrome checking**
|
||||||
|
|
||||||
|
- A simple two-pointer approach works: compare characters from both ends moving inward
|
||||||
|
- For optimization, you can precompute all palindrome substrings using dynamic programming
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
The backtracking ensures we explore all possibilities while the palindrome check prunes invalid branches early.
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Forgetting to Backtrack
|
||||||
|
description: |
|
||||||
|
A common mistake is adding a substring to the path but forgetting to remove it after the recursive call returns.
|
||||||
|
|
||||||
|
Without proper backtracking, substrings from one branch of exploration contaminate other branches, leading to incorrect or duplicate results.
|
||||||
|
|
||||||
|
Always pair each `path.append()` with a corresponding `path.pop()` after recursion.
|
||||||
|
wrong_approach: "Append to path without removing after recursion"
|
||||||
|
correct_approach: "Always pop from path after recursive call returns"
|
||||||
|
|
||||||
|
- title: Copying References Instead of Values
|
||||||
|
description: |
|
||||||
|
When adding a valid partition to results, you must add a **copy** of the path list, not the path itself.
|
||||||
|
|
||||||
|
In Python, `result.append(path)` appends a reference. As you continue modifying `path`, all previously added results change too — you end up with duplicates of the final state.
|
||||||
|
|
||||||
|
Use `result.append(path[:])` or `result.append(list(path))` to add a snapshot.
|
||||||
|
wrong_approach: "result.append(path)"
|
||||||
|
correct_approach: "result.append(path[:]) or result.append(list(path))"
|
||||||
|
|
||||||
|
- title: Inefficient Palindrome Checking
|
||||||
|
description: |
|
||||||
|
Checking if a substring is a palindrome takes O(n) time. If you're checking the same substrings repeatedly across different branches, this becomes wasteful.
|
||||||
|
|
||||||
|
With `s.length <= 16`, the naive approach works fine. But for longer strings, consider **precomputing** a 2D table where `is_palindrome[i][j]` indicates whether `s[i:j+1]` is a palindrome. This DP preprocessing takes O(n^2) time but makes each lookup O(1).
|
||||||
|
wrong_approach: "Recompute palindrome check for same substring multiple times"
|
||||||
|
correct_approach: "Precompute palindrome table with DP for O(1) lookups"
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Backtracking template**: This problem follows the classic pattern — make a choice, recurse, undo the choice (backtrack)"
|
||||||
|
- "**Decision tree thinking**: Visualize the problem as exploring all paths in a tree where each node represents a partitioning decision"
|
||||||
|
- "**Copy vs reference**: When collecting results in backtracking, always copy the current path to avoid reference bugs"
|
||||||
|
- "**Optimization opportunity**: Precomputing palindrome substrings with DP is a common optimization that extends to related problems"
|
||||||
|
|
||||||
|
time_complexity: "O(n * 2^n). In the worst case (all characters identical), there are 2^(n-1) possible partitions, and generating each takes O(n) to copy."
|
||||||
|
space_complexity: "O(n). The recursion depth is at most n, and the path list holds at most n substrings. Output storage is not counted."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Backtracking
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def partition(s: str) -> list[list[str]]:
|
||||||
|
result = []
|
||||||
|
|
||||||
|
def is_palindrome(sub: str) -> bool:
|
||||||
|
# Two-pointer check: compare from both ends
|
||||||
|
left, right = 0, len(sub) - 1
|
||||||
|
while left < right:
|
||||||
|
if sub[left] != sub[right]:
|
||||||
|
return False
|
||||||
|
left += 1
|
||||||
|
right -= 1
|
||||||
|
return True
|
||||||
|
|
||||||
|
def backtrack(start: int, path: list[str]) -> None:
|
||||||
|
# Base case: partitioned the entire string
|
||||||
|
if start == len(s):
|
||||||
|
result.append(path[:]) # Add a copy, not reference
|
||||||
|
return
|
||||||
|
|
||||||
|
# Try all possible end positions for current substring
|
||||||
|
for end in range(start, len(s)):
|
||||||
|
substring = s[start:end + 1]
|
||||||
|
# Only proceed if this substring is a palindrome
|
||||||
|
if is_palindrome(substring):
|
||||||
|
path.append(substring) # Make choice
|
||||||
|
backtrack(end + 1, path) # Explore
|
||||||
|
path.pop() # Undo choice (backtrack)
|
||||||
|
|
||||||
|
backtrack(0, [])
|
||||||
|
return result
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n * 2^n) — In the worst case, every partition is valid (e.g., "aaa"), giving 2^(n-1) partitions. Each partition takes O(n) to copy.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n) — Recursion stack depth plus the path list.
|
||||||
|
|
||||||
|
This approach explores all valid partitions by trying every possible cut point at each position. The palindrome check prunes invalid branches early, but in the worst case (all identical characters), we still explore all 2^(n-1) possibilities.
|
||||||
|
|
||||||
|
- approach_name: Backtracking with DP Precomputation
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def partition(s: str) -> list[list[str]]:
|
||||||
|
n = len(s)
|
||||||
|
result = []
|
||||||
|
|
||||||
|
# Precompute palindrome table using DP
|
||||||
|
# is_pal[i][j] = True if s[i:j+1] is a palindrome
|
||||||
|
is_pal = [[False] * n for _ in range(n)]
|
||||||
|
|
||||||
|
# Single characters are palindromes
|
||||||
|
for i in range(n):
|
||||||
|
is_pal[i][i] = True
|
||||||
|
|
||||||
|
# Check substrings of length 2+
|
||||||
|
for length in range(2, n + 1):
|
||||||
|
for i in range(n - length + 1):
|
||||||
|
j = i + length - 1
|
||||||
|
if s[i] == s[j]:
|
||||||
|
# Two chars or inner substring is palindrome
|
||||||
|
is_pal[i][j] = (length == 2) or is_pal[i + 1][j - 1]
|
||||||
|
|
||||||
|
def backtrack(start: int, path: list[str]) -> None:
|
||||||
|
if start == n:
|
||||||
|
result.append(path[:])
|
||||||
|
return
|
||||||
|
|
||||||
|
for end in range(start, n):
|
||||||
|
# O(1) palindrome lookup instead of O(n) check
|
||||||
|
if is_pal[start][end]:
|
||||||
|
path.append(s[start:end + 1])
|
||||||
|
backtrack(end + 1, path)
|
||||||
|
path.pop()
|
||||||
|
|
||||||
|
backtrack(0, [])
|
||||||
|
return result
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n^2 + n * 2^n) — O(n^2) for DP precomputation, plus O(n * 2^n) for backtracking. The overall complexity remains O(n * 2^n).
|
||||||
|
|
||||||
|
**Space Complexity:** O(n^2) — The palindrome lookup table dominates.
|
||||||
|
|
||||||
|
This optimization precomputes which substrings are palindromes using dynamic programming. Each palindrome check becomes O(1) instead of O(n). While this doesn't improve worst-case time complexity (since the number of partitions dominates), it speeds up the palindrome checks significantly in practice.
|
||||||
220
backend/data/questions/palindromic-substrings.yaml
Normal file
220
backend/data/questions/palindromic-substrings.yaml
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
title: Palindromic Substrings
|
||||||
|
slug: palindromic-substrings
|
||||||
|
difficulty: medium
|
||||||
|
leetcode_id: 647
|
||||||
|
leetcode_url: https://leetcode.com/problems/palindromic-substrings/
|
||||||
|
categories:
|
||||||
|
- strings
|
||||||
|
- dynamic-programming
|
||||||
|
patterns:
|
||||||
|
- two-pointers
|
||||||
|
- dynamic-programming
|
||||||
|
|
||||||
|
description: |
|
||||||
|
Given a string `s`, return *the number of **palindromic substrings** in it*.
|
||||||
|
|
||||||
|
A string is a **palindrome** when it reads the same backward as forward.
|
||||||
|
|
||||||
|
A **substring** is a contiguous sequence of characters within the string.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `1 <= s.length <= 1000`
|
||||||
|
- `s` consists of lowercase English letters.
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: 's = "abc"'
|
||||||
|
output: "3"
|
||||||
|
explanation: "Three palindromic strings: \"a\", \"b\", \"c\"."
|
||||||
|
- input: 's = "aaa"'
|
||||||
|
output: "6"
|
||||||
|
explanation: 'Six palindromic strings: "a", "a", "a", "aa", "aa", "aaa".'
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Imagine each character in the string as a potential **centre** of a palindrome. A palindrome is symmetric — it mirrors around its centre.
|
||||||
|
|
||||||
|
The key insight is that palindromes can have either:
|
||||||
|
- **Odd length**: A single character at the centre (e.g., "aba" centres on "b")
|
||||||
|
- **Even length**: Two identical characters at the centre (e.g., "abba" centres between the two "b"s)
|
||||||
|
|
||||||
|
Think of it like dropping a pebble into still water and watching ripples expand outward. From each centre point, we **expand outward** in both directions as long as the characters on both sides match. Each successful expansion represents another valid palindrome we've found.
|
||||||
|
|
||||||
|
By systematically considering every possible centre (both single characters and pairs of adjacent characters), we can count all palindromic substrings without missing any or counting duplicates.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using the **Expand Around Centre** approach:
|
||||||
|
|
||||||
|
**Step 1: Initialise a counter**
|
||||||
|
|
||||||
|
- `count`: Set to `0` to track the total number of palindromic substrings
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Iterate through each potential centre**
|
||||||
|
|
||||||
|
- For each index `i` from `0` to `n-1`, treat it as a potential centre
|
||||||
|
- Each position can be the centre of both odd-length and even-length palindromes
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Expand around odd-length centres**
|
||||||
|
|
||||||
|
- Call `expand(s, i, i)` — starting with a single character centre
|
||||||
|
- Expand outward while `s[left] == s[right]`
|
||||||
|
- Each successful comparison means we found another palindrome
|
||||||
|
- Continue until characters don't match or we hit boundaries
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 4: Expand around even-length centres**
|
||||||
|
|
||||||
|
- Call `expand(s, i, i + 1)` — starting with two adjacent characters as the centre
|
||||||
|
- Same expansion logic as odd-length centres
|
||||||
|
- This catches palindromes like "aa", "abba", etc.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 5: Return the total count**
|
||||||
|
|
||||||
|
- Return `count` after processing all centres
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
This approach is efficient because each expansion takes O(n) time in the worst case, and we have O(n) centres, giving us O(n²) total — much better than checking all O(n²) substrings explicitly.
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Checking All Substrings Naively
|
||||||
|
description: |
|
||||||
|
A common first approach is to generate all substrings and check each one for being a palindrome:
|
||||||
|
- Outer loop for start index: O(n)
|
||||||
|
- Inner loop for end index: O(n)
|
||||||
|
- Palindrome check for each substring: O(n)
|
||||||
|
|
||||||
|
This results in **O(n³) time complexity**. While it passes for `n <= 1000`, it's significantly slower than necessary. For larger inputs (in similar problems), this approach would cause TLE.
|
||||||
|
wrong_approach: "Generate all substrings, check each for palindrome"
|
||||||
|
correct_approach: "Expand around centres in O(n²)"
|
||||||
|
|
||||||
|
- title: Forgetting Even-Length Palindromes
|
||||||
|
description: |
|
||||||
|
When expanding from centres, it's easy to only consider single characters as centres (odd-length palindromes).
|
||||||
|
|
||||||
|
For example, in "abba", if you only expand from single characters, you'll find "a", "b", "b", "a" but miss "bb" and "abba".
|
||||||
|
|
||||||
|
You must expand from both:
|
||||||
|
- Single characters: `expand(i, i)` for odd-length
|
||||||
|
- Adjacent pairs: `expand(i, i+1)` for even-length
|
||||||
|
wrong_approach: "Only expand from single character centres"
|
||||||
|
correct_approach: "Expand from both single characters and adjacent pairs"
|
||||||
|
|
||||||
|
- title: Off-by-One Errors in Expansion
|
||||||
|
description: |
|
||||||
|
When expanding outward, boundary checks are crucial. The expansion should stop when:
|
||||||
|
- `left < 0` (hit the start of string)
|
||||||
|
- `right >= n` (hit the end of string)
|
||||||
|
- `s[left] != s[right]` (characters don't match)
|
||||||
|
|
||||||
|
A common mistake is checking boundaries after accessing the characters, causing index out of bounds errors.
|
||||||
|
wrong_approach: "Check boundaries after character comparison"
|
||||||
|
correct_approach: "Check boundaries before accessing characters"
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Expand around centre pattern**: For palindrome problems, thinking in terms of centres rather than endpoints often leads to cleaner solutions"
|
||||||
|
- "**Odd vs even length**: Always consider both cases when dealing with palindromes — single character centres and two-character centres"
|
||||||
|
- "**Foundation for Longest Palindromic Substring**: The same expand-around-centre technique solves LeetCode 5, just track the longest instead of counting"
|
||||||
|
- "**Alternative: Dynamic Programming**: This problem can also be solved with DP where `dp[i][j]` indicates if `s[i:j+1]` is a palindrome, useful for problems requiring the actual substrings"
|
||||||
|
|
||||||
|
time_complexity: "O(n²). For each of the `n` positions, we potentially expand up to `n` times in each direction."
|
||||||
|
space_complexity: "O(1). We only use a constant number of variables for counting and expansion indices."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Expand Around Centre
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def count_substrings(s: str) -> int:
|
||||||
|
def expand(left: int, right: int) -> int:
|
||||||
|
"""Count palindromes by expanding from centre."""
|
||||||
|
count = 0
|
||||||
|
# Expand while within bounds and characters match
|
||||||
|
while left >= 0 and right < len(s) and s[left] == s[right]:
|
||||||
|
count += 1 # Found a palindrome
|
||||||
|
left -= 1 # Expand left
|
||||||
|
right += 1 # Expand right
|
||||||
|
return count
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
for i in range(len(s)):
|
||||||
|
# Odd-length palindromes (single character centre)
|
||||||
|
total += expand(i, i)
|
||||||
|
# Even-length palindromes (two character centre)
|
||||||
|
total += expand(i, i + 1)
|
||||||
|
|
||||||
|
return total
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n²) — For each of n centres, expansion can take up to O(n) time.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — Only using a few integer variables.
|
||||||
|
|
||||||
|
We iterate through each position as a potential centre and expand outward for both odd and even length palindromes. Each expansion continues while characters match, counting each valid palindrome found.
|
||||||
|
|
||||||
|
- approach_name: Dynamic Programming
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def count_substrings(s: str) -> int:
|
||||||
|
n = len(s)
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
# dp[i][j] = True if s[i:j+1] is a palindrome
|
||||||
|
dp = [[False] * n for _ in range(n)]
|
||||||
|
|
||||||
|
# Single characters are palindromes
|
||||||
|
for i in range(n):
|
||||||
|
dp[i][i] = True
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
# Check substrings of length 2
|
||||||
|
for i in range(n - 1):
|
||||||
|
if s[i] == s[i + 1]:
|
||||||
|
dp[i][i + 1] = True
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
# Check substrings of length 3 and above
|
||||||
|
for length in range(3, n + 1):
|
||||||
|
for i in range(n - length + 1):
|
||||||
|
j = i + length - 1
|
||||||
|
# Palindrome if ends match and middle is palindrome
|
||||||
|
if s[i] == s[j] and dp[i + 1][j - 1]:
|
||||||
|
dp[i][j] = True
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
return count
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n²) — We fill an n×n DP table.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n²) — We store the DP table.
|
||||||
|
|
||||||
|
This approach builds up from smaller substrings: single characters are palindromes, length-2 substrings are palindromes if both characters match, and longer substrings are palindromes if the ends match and the middle (already computed) is a palindrome. While correct and useful for understanding, the expand-around-centre approach is preferred due to O(1) space.
|
||||||
|
|
||||||
|
- approach_name: Brute Force
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def count_substrings(s: str) -> int:
|
||||||
|
def is_palindrome(substring: str) -> bool:
|
||||||
|
"""Check if a string is a palindrome."""
|
||||||
|
return substring == substring[::-1]
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
n = len(s)
|
||||||
|
|
||||||
|
# Check all possible substrings
|
||||||
|
for i in range(n):
|
||||||
|
for j in range(i, n):
|
||||||
|
if is_palindrome(s[i:j + 1]):
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
return count
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n³) — O(n²) substrings, each taking O(n) to check.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n) — Creating substring copies for comparison.
|
||||||
|
|
||||||
|
This approach explicitly generates every substring and checks if it's a palindrome by comparing it to its reverse. While straightforward and correct, it's significantly slower than the optimal approaches. Included to illustrate the progression from naive to optimal thinking.
|
||||||
220
backend/data/questions/partition-equal-subset-sum.yaml
Normal file
220
backend/data/questions/partition-equal-subset-sum.yaml
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
title: Partition Equal Subset Sum
|
||||||
|
slug: partition-equal-subset-sum
|
||||||
|
difficulty: medium
|
||||||
|
leetcode_id: 416
|
||||||
|
leetcode_url: https://leetcode.com/problems/partition-equal-subset-sum/
|
||||||
|
categories:
|
||||||
|
- arrays
|
||||||
|
- dynamic-programming
|
||||||
|
patterns:
|
||||||
|
- dynamic-programming
|
||||||
|
|
||||||
|
description: |
|
||||||
|
Given an integer array `nums`, return `true` *if you can partition the array into two subsets such that the sum of the elements in both subsets is equal* or `false` *otherwise*.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `1 <= nums.length <= 200`
|
||||||
|
- `1 <= nums[i] <= 100`
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: "nums = [1,5,11,5]"
|
||||||
|
output: "true"
|
||||||
|
explanation: "The array can be partitioned as [1, 5, 5] and [11]."
|
||||||
|
- input: "nums = [1,2,3,5]"
|
||||||
|
output: "false"
|
||||||
|
explanation: "The array cannot be partitioned into equal sum subsets."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
At first glance, this looks like a partitioning problem where we need to divide an array into two groups. But here's the key insight: if we can partition the array into two subsets with **equal sums**, then each subset must sum to exactly **half of the total array sum**.
|
||||||
|
|
||||||
|
Think of it like balancing a scale. If the total weight is 22, each side needs exactly 11 to balance. This transforms our problem: instead of finding two equal subsets, we just need to find **one subset that sums to `total_sum / 2`**. The remaining elements automatically form the other subset.
|
||||||
|
|
||||||
|
This is a classic **0/1 Knapsack** problem in disguise. Imagine you have a knapsack with capacity `total_sum / 2`. Each number in the array is an item you can either include or exclude. Can you fill the knapsack exactly?
|
||||||
|
|
||||||
|
Two immediate observations help us prune:
|
||||||
|
- If the total sum is **odd**, it's impossible to split into two equal integer sums — return `false` immediately
|
||||||
|
- If any single element exceeds `total_sum / 2`, it can't fit in either subset — return `false`
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We use **Dynamic Programming** with a boolean array to track achievable sums.
|
||||||
|
|
||||||
|
**Step 1: Calculate the target**
|
||||||
|
|
||||||
|
- Compute `total_sum` of all elements
|
||||||
|
- If `total_sum` is odd, return `false` immediately (can't split evenly)
|
||||||
|
- Set `target = total_sum // 2`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Initialise the DP array**
|
||||||
|
|
||||||
|
- Create a boolean array `dp` of size `target + 1`
|
||||||
|
- `dp[i]` represents: "Can we achieve sum `i` using some subset of numbers seen so far?"
|
||||||
|
- Set `dp[0] = True` — we can always achieve sum 0 by selecting nothing
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Process each number**
|
||||||
|
|
||||||
|
- For each `num` in the array, iterate **backwards** from `target` down to `num`
|
||||||
|
- For each sum `j`, if `dp[j - num]` is `True`, then `dp[j]` becomes `True`
|
||||||
|
- We iterate backwards to avoid using the same number twice in one iteration
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 4: Return the result**
|
||||||
|
|
||||||
|
- Return `dp[target]` — whether we can achieve exactly half the total sum
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
The backward iteration is crucial: if we went forward, adding a number could affect later calculations in the same pass, effectively "using" the number multiple times.
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Forward Iteration Leads to Reusing Elements
|
||||||
|
description: |
|
||||||
|
A subtle but critical bug occurs if you iterate forward through sums instead of backward.
|
||||||
|
|
||||||
|
Consider `nums = [1, 2]` with `target = 3`. If we process `1` going forward:
|
||||||
|
- `dp[1] = True` (we can make 1)
|
||||||
|
- Then checking `dp[2]`: `dp[2 - 1] = dp[1] = True`, so `dp[2] = True`
|
||||||
|
|
||||||
|
But wait — we've effectively used `1` twice! The backward iteration prevents this by ensuring we only consider sums achievable *before* adding the current number.
|
||||||
|
wrong_approach: "Forward iteration: for j in range(num, target + 1)"
|
||||||
|
correct_approach: "Backward iteration: for j in range(target, num - 1, -1)"
|
||||||
|
|
||||||
|
- title: Forgetting the Odd Sum Early Exit
|
||||||
|
description: |
|
||||||
|
If the total sum is odd (e.g., 11), there's no way to split it into two equal integer parts. Without this check, the algorithm would waste time computing a DP table for an impossible target.
|
||||||
|
|
||||||
|
Always check `if total_sum % 2 != 0: return False` before proceeding.
|
||||||
|
wrong_approach: "Skipping the odd check and computing DP anyway"
|
||||||
|
correct_approach: "Return False immediately when total_sum is odd"
|
||||||
|
|
||||||
|
- title: Not Handling Large Single Elements
|
||||||
|
description: |
|
||||||
|
If any element exceeds `target`, it cannot be part of either subset that sums to `target`. While the DP naturally handles this (we skip sums less than `num`), an explicit check can provide an early exit.
|
||||||
|
|
||||||
|
For `nums = [1, 2, 100]` with `total_sum = 103`, this is odd so we'd exit anyway. But for `nums = [1, 100, 1]` with `total_sum = 102` and `target = 51`, the `100` makes partitioning impossible.
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Subset sum is 0/1 Knapsack**: Recognise that finding a subset with a target sum is equivalent to the classic knapsack problem"
|
||||||
|
- "**Transform the problem**: Instead of finding two equal subsets, find one subset summing to half the total — much simpler"
|
||||||
|
- "**Backward DP iteration**: When each element can only be used once, iterate backwards to prevent double-counting"
|
||||||
|
- "**Early pruning matters**: Odd total sums are impossible; check this first for an O(1) exit in many cases"
|
||||||
|
|
||||||
|
time_complexity: "O(n × target) where `n` is the array length and `target = sum(nums) / 2`. We process each number once and update up to `target` sums."
|
||||||
|
space_complexity: "O(target). We use a 1D DP array of size `target + 1`. This can be up to O(n × max_element / 2) = O(n × 50) = O(n × 50) in the worst case given the constraints."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: 1D Dynamic Programming
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def can_partition(nums: list[int]) -> bool:
|
||||||
|
total_sum = sum(nums)
|
||||||
|
|
||||||
|
# Odd sum can't be split into two equal parts
|
||||||
|
if total_sum % 2 != 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
target = total_sum // 2
|
||||||
|
|
||||||
|
# dp[i] = True if we can achieve sum i with some subset
|
||||||
|
dp = [False] * (target + 1)
|
||||||
|
dp[0] = True # Sum of 0 is always achievable (empty subset)
|
||||||
|
|
||||||
|
for num in nums:
|
||||||
|
# Iterate backwards to avoid using same num twice
|
||||||
|
for j in range(target, num - 1, -1):
|
||||||
|
# If we could make (j - num), we can now make j
|
||||||
|
if dp[j - num]:
|
||||||
|
dp[j] = True
|
||||||
|
|
||||||
|
# Early exit if we've found the target
|
||||||
|
if dp[target]:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return dp[target]
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n × target) — For each of the n numbers, we potentially update target sums.
|
||||||
|
|
||||||
|
**Space Complexity:** O(target) — Single array of size target + 1.
|
||||||
|
|
||||||
|
This optimised 1D approach uses the insight that we only need the previous row's values, and by iterating backwards, we can update in place without a second array.
|
||||||
|
|
||||||
|
- approach_name: 2D Dynamic Programming
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def can_partition(nums: list[int]) -> bool:
|
||||||
|
total_sum = sum(nums)
|
||||||
|
|
||||||
|
# Odd sum can't be split into two equal parts
|
||||||
|
if total_sum % 2 != 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
target = total_sum // 2
|
||||||
|
n = len(nums)
|
||||||
|
|
||||||
|
# dp[i][j] = True if first i elements can sum to j
|
||||||
|
dp = [[False] * (target + 1) for _ in range(n + 1)]
|
||||||
|
|
||||||
|
# Base case: sum 0 is achievable with any number of elements
|
||||||
|
for i in range(n + 1):
|
||||||
|
dp[i][0] = True
|
||||||
|
|
||||||
|
for i in range(1, n + 1):
|
||||||
|
num = nums[i - 1]
|
||||||
|
for j in range(1, target + 1):
|
||||||
|
# Don't include current number
|
||||||
|
dp[i][j] = dp[i - 1][j]
|
||||||
|
|
||||||
|
# Include current number if it fits
|
||||||
|
if j >= num:
|
||||||
|
dp[i][j] = dp[i][j] or dp[i - 1][j - num]
|
||||||
|
|
||||||
|
return dp[n][target]
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n × target) — Same as 1D approach.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n × target) — Full 2D table.
|
||||||
|
|
||||||
|
This classic 2D DP makes the state transitions clearer: for each element, we either include it (look at `dp[i-1][j-num]`) or exclude it (look at `dp[i-1][j]`). While easier to understand, it uses more memory than necessary.
|
||||||
|
|
||||||
|
- approach_name: Recursive with Memoization
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def can_partition(nums: list[int]) -> bool:
|
||||||
|
total_sum = sum(nums)
|
||||||
|
|
||||||
|
if total_sum % 2 != 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
target = total_sum // 2
|
||||||
|
memo = {}
|
||||||
|
|
||||||
|
def dp(index: int, remaining: int) -> bool:
|
||||||
|
# Base cases
|
||||||
|
if remaining == 0:
|
||||||
|
return True
|
||||||
|
if index >= len(nums) or remaining < 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check memo
|
||||||
|
if (index, remaining) in memo:
|
||||||
|
return memo[(index, remaining)]
|
||||||
|
|
||||||
|
# Try including or excluding current element
|
||||||
|
result = (dp(index + 1, remaining - nums[index]) or
|
||||||
|
dp(index + 1, remaining))
|
||||||
|
|
||||||
|
memo[(index, remaining)] = result
|
||||||
|
return result
|
||||||
|
|
||||||
|
return dp(0, target)
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n × target) — Each unique (index, remaining) state computed once.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n × target) for memoization + O(n) recursion stack.
|
||||||
|
|
||||||
|
This top-down approach may be more intuitive for those who think recursively. At each index, we branch: either include the current number (subtract from remaining) or skip it. Memoization prevents recomputation of identical subproblems.
|
||||||
186
backend/data/questions/partition-labels.yaml
Normal file
186
backend/data/questions/partition-labels.yaml
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
title: Partition Labels
|
||||||
|
slug: partition-labels
|
||||||
|
difficulty: medium
|
||||||
|
leetcode_id: 763
|
||||||
|
leetcode_url: https://leetcode.com/problems/partition-labels/
|
||||||
|
categories:
|
||||||
|
- strings
|
||||||
|
- hash-tables
|
||||||
|
- two-pointers
|
||||||
|
patterns:
|
||||||
|
- greedy
|
||||||
|
- two-pointers
|
||||||
|
|
||||||
|
description: |
|
||||||
|
You are given a string `s`. We want to partition the string into as many parts as possible so that each letter appears in **at most one part**.
|
||||||
|
|
||||||
|
For example, the string `"ababcc"` can be partitioned into `["abab", "cc"]`, but partitions such as `["aba", "bcc"]` or `["ab", "ab", "cc"]` are invalid.
|
||||||
|
|
||||||
|
Note that the partition is done so that after concatenating all the parts in order, the resultant string should be `s`.
|
||||||
|
|
||||||
|
Return *a list of integers representing the size of these parts*.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `1 <= s.length <= 500`
|
||||||
|
- `s` consists of lowercase English letters.
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: 's = "ababcbacadefegdehijhklij"'
|
||||||
|
output: "[9, 7, 8]"
|
||||||
|
explanation: 'The partition is "ababcbaca", "defegde", "hijhklij". This is a partition so that each letter appears in at most one part. A partition like "ababcbacadefegde", "hijhklij" is incorrect, because it splits s into fewer parts.'
|
||||||
|
- input: 's = "eccbbbbdec"'
|
||||||
|
output: "[10]"
|
||||||
|
explanation: "All characters overlap, so the entire string must be one partition."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Imagine you're reading the string from left to right and trying to cut it into pieces. The challenge is that once you've seen a character, you can't cut until you've passed its **last occurrence** in the string.
|
||||||
|
|
||||||
|
Think of it like this: each character "claims" a range from its first to its last appearance. When you encounter a character, you're committing to include everything up to where it last appears. If along the way you see other characters, they might extend your commitment even further.
|
||||||
|
|
||||||
|
The key insight is that a partition can end only when you've reached a position where **all characters seen so far have had their last occurrence**. At that moment, you know no character from this segment will appear later, so it's safe to cut.
|
||||||
|
|
||||||
|
By tracking the "furthest reach" — the maximum last occurrence of any character seen — you know exactly when each partition can end.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using a **Greedy Two-Pass Approach**:
|
||||||
|
|
||||||
|
**Step 1: Build a last occurrence map**
|
||||||
|
|
||||||
|
- Iterate through the string once
|
||||||
|
- For each character, record its last index in a hash map
|
||||||
|
- After this pass, `last[c]` tells us the rightmost position where character `c` appears
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Initialise partition tracking variables**
|
||||||
|
|
||||||
|
- `start`: The beginning of the current partition (initially `0`)
|
||||||
|
- `end`: The furthest we must extend the current partition (initially `0`)
|
||||||
|
- `result`: A list to store partition sizes
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Iterate through the string again**
|
||||||
|
|
||||||
|
- For each character at index `i`, update `end` to be the maximum of its current value and `last[s[i]]`
|
||||||
|
- This "extends" our partition boundary if the current character appears later
|
||||||
|
- When `i == end`, we've reached the end of a partition:
|
||||||
|
- The current partition spans from `start` to `end`
|
||||||
|
- Append its size (`end - start + 1`) to the result
|
||||||
|
- Set `start = i + 1` for the next partition
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 4: Return the result**
|
||||||
|
|
||||||
|
- After processing all characters, return the list of partition sizes
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
This greedy approach works because at each position we're making the minimum necessary extension to ensure no character spans multiple partitions.
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Trying to Partition Greedily Without Lookahead
|
||||||
|
description: |
|
||||||
|
A common mistake is trying to determine partition boundaries on a single pass without knowing where characters last appear.
|
||||||
|
|
||||||
|
For example, with `s = "aba"`, if you try to end a partition after the first `'a'`, you'll incorrectly split it as `["a", "ba"]`. But `'a'` appears again at index 2, so the entire string must be one partition: `["aba"]`.
|
||||||
|
|
||||||
|
The solution is to **first build a map of last occurrences**, giving you the lookahead information you need.
|
||||||
|
wrong_approach: "Single pass without preprocessing"
|
||||||
|
correct_approach: "Two-pass: first map last occurrences, then greedily partition"
|
||||||
|
|
||||||
|
- title: Forgetting to Extend the Partition Boundary
|
||||||
|
description: |
|
||||||
|
When iterating through a partition, you must continuously update the `end` boundary as you encounter new characters.
|
||||||
|
|
||||||
|
Consider `s = "abc"` where `last['a'] = 0`, `last['b'] = 1`, `last['c'] = 2`. If you only set `end` once at the start of each partition, you'd incorrectly create three partitions. Instead, each character might extend `end` further.
|
||||||
|
wrong_approach: "Setting end only once per partition"
|
||||||
|
correct_approach: "Update end = max(end, last[s[i]]) at every index"
|
||||||
|
|
||||||
|
- title: Off-by-One Errors in Partition Size
|
||||||
|
description: |
|
||||||
|
The size of a partition from index `start` to `end` is `end - start + 1`, not `end - start`.
|
||||||
|
|
||||||
|
For example, if a partition spans indices `0` to `8` (inclusive), the size is `9`, not `8`. Forgetting the `+ 1` will produce incorrect sizes.
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Preprocessing enables greedy choices**: Building a last occurrence map gives you the lookahead information needed to make optimal decisions in a single pass"
|
||||||
|
- "**Interval extension pattern**: This technique of tracking a 'furthest reach' that extends as you encounter new elements applies to many interval and partition problems"
|
||||||
|
- "**Two-pass is often necessary**: When a greedy decision depends on future information, a preprocessing pass is a common and efficient solution"
|
||||||
|
- "**Similar problems**: This pattern appears in Jump Game, Merge Intervals, and other problems involving overlapping ranges"
|
||||||
|
|
||||||
|
time_complexity: "O(n). We traverse the string twice — once to build the last occurrence map, once to determine partitions."
|
||||||
|
space_complexity: "O(1). The hash map stores at most 26 entries (one per lowercase letter), which is constant regardless of input size."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Greedy with Last Occurrence Map
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def partition_labels(s: str) -> list[int]:
|
||||||
|
# First pass: record last occurrence of each character
|
||||||
|
last = {}
|
||||||
|
for i, c in enumerate(s):
|
||||||
|
last[c] = i
|
||||||
|
|
||||||
|
result = []
|
||||||
|
start = 0 # Start of current partition
|
||||||
|
end = 0 # Furthest we must extend current partition
|
||||||
|
|
||||||
|
# Second pass: greedily determine partitions
|
||||||
|
for i, c in enumerate(s):
|
||||||
|
# Extend partition to include last occurrence of current char
|
||||||
|
end = max(end, last[c])
|
||||||
|
|
||||||
|
# If we've reached the end of current partition
|
||||||
|
if i == end:
|
||||||
|
# Record partition size and start new partition
|
||||||
|
result.append(end - start + 1)
|
||||||
|
start = i + 1
|
||||||
|
|
||||||
|
return result
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — Two linear passes through the string.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — The hash map holds at most 26 keys (one per lowercase letter).
|
||||||
|
|
||||||
|
The first pass builds a map of each character's last index. The second pass uses this information to greedily extend each partition until we reach a point where all characters in the current segment have had their last occurrence. At that moment, we record the partition and start a new one.
|
||||||
|
|
||||||
|
- approach_name: Interval Merging
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def partition_labels(s: str) -> list[int]:
|
||||||
|
# Build intervals: [first_occurrence, last_occurrence] for each char
|
||||||
|
intervals = {}
|
||||||
|
for i, c in enumerate(s):
|
||||||
|
if c not in intervals:
|
||||||
|
intervals[c] = [i, i]
|
||||||
|
else:
|
||||||
|
intervals[c][1] = i
|
||||||
|
|
||||||
|
# Merge overlapping intervals in order of first occurrence
|
||||||
|
sorted_intervals = sorted(intervals.values())
|
||||||
|
|
||||||
|
result = []
|
||||||
|
start, end = sorted_intervals[0]
|
||||||
|
|
||||||
|
for first, last in sorted_intervals[1:]:
|
||||||
|
if first <= end:
|
||||||
|
# Overlapping - extend the current interval
|
||||||
|
end = max(end, last)
|
||||||
|
else:
|
||||||
|
# Non-overlapping - record previous and start new
|
||||||
|
result.append(end - start + 1)
|
||||||
|
start, end = first, last
|
||||||
|
|
||||||
|
# Don't forget the last interval
|
||||||
|
result.append(end - start + 1)
|
||||||
|
|
||||||
|
return result
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n + k log k) where k is the number of unique characters (at most 26).
|
||||||
|
|
||||||
|
**Space Complexity:** O(k) — Storage for intervals.
|
||||||
|
|
||||||
|
This approach explicitly models the problem as interval merging. Each character defines an interval from its first to last occurrence. We then merge overlapping intervals and return the sizes of merged intervals. While conceptually clear, the sorting step adds overhead compared to the greedy approach.
|
||||||
217
backend/data/questions/partition-to-k-equal-sum-subsets.yaml
Normal file
217
backend/data/questions/partition-to-k-equal-sum-subsets.yaml
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
title: Partition to K Equal Sum Subsets
|
||||||
|
slug: partition-to-k-equal-sum-subsets
|
||||||
|
difficulty: medium
|
||||||
|
leetcode_id: 698
|
||||||
|
leetcode_url: https://leetcode.com/problems/partition-to-k-equal-sum-subsets/
|
||||||
|
categories:
|
||||||
|
- arrays
|
||||||
|
- dynamic-programming
|
||||||
|
- recursion
|
||||||
|
patterns:
|
||||||
|
- backtracking
|
||||||
|
|
||||||
|
description: |
|
||||||
|
Given an integer array `nums` and an integer `k`, return `true` if it is possible to divide this array into `k` non-empty subsets whose sums are all equal.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `1 <= k <= nums.length <= 16`
|
||||||
|
- `1 <= nums[i] <= 10^4`
|
||||||
|
- The frequency of each element is in the range `[1, 4]`
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: "nums = [4,3,2,3,5,2,1], k = 4"
|
||||||
|
output: "true"
|
||||||
|
explanation: "It is possible to divide it into 4 subsets (5), (1,4), (2,3), (2,3) with equal sums of 5 each."
|
||||||
|
- input: "nums = [1,2,3,4], k = 3"
|
||||||
|
output: "false"
|
||||||
|
explanation: "The total sum is 10, which is not divisible by 3, so equal partition is impossible."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Imagine you have `k` empty buckets, and each bucket must hold exactly the same total weight. Your task is to distribute all the items (numbers) from the array into these buckets such that every bucket ends up with the same sum.
|
||||||
|
|
||||||
|
The first insight is mathematical: if we can partition the array into `k` equal-sum subsets, then each subset must have sum equal to `total_sum / k`. If the total sum isn't divisible by `k`, we can immediately return `false`.
|
||||||
|
|
||||||
|
Think of it like this: you're trying to fill `k` buckets one by one. For each number, you try placing it in a bucket. If a bucket would overflow (exceed the target sum), you skip it. If you successfully fill all buckets to exactly the target, you've found a valid partition.
|
||||||
|
|
||||||
|
The key constraint `nums.length <= 16` is a hint — this small size allows exponential-time solutions like backtracking. The challenge is to prune the search space efficiently to avoid exploring redundant paths.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using **Backtracking with Pruning**:
|
||||||
|
|
||||||
|
**Step 1: Early validation**
|
||||||
|
|
||||||
|
- Calculate `total_sum` of all elements
|
||||||
|
- If `total_sum % k != 0`, return `false` immediately — equal partition is impossible
|
||||||
|
- Calculate `target = total_sum / k` — this is the sum each bucket must have
|
||||||
|
- If any single element exceeds `target`, return `false` — it can't fit in any bucket
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Sort in descending order**
|
||||||
|
|
||||||
|
- Sort `nums` in descending order
|
||||||
|
- This crucial optimisation places larger numbers first, failing faster when a bucket can't fit
|
||||||
|
- Large numbers have fewer placement options, so trying them early prunes more branches
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Backtracking with k buckets**
|
||||||
|
|
||||||
|
- Create an array `buckets` of size `k`, all initialised to `0`
|
||||||
|
- For each number (in descending order), try placing it in each bucket
|
||||||
|
- A number can go in bucket `i` if `buckets[i] + num <= target`
|
||||||
|
- If placement succeeds, recursively try to place the next number
|
||||||
|
- If all numbers are placed successfully, return `true`
|
||||||
|
- If no valid placement exists, backtrack (remove number from bucket) and try the next bucket
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 4: Skip duplicate buckets**
|
||||||
|
|
||||||
|
- If `buckets[i] == buckets[i-1]`, skip bucket `i` — placing the number there would lead to the same configuration we'll explore via bucket `i-1`
|
||||||
|
- This avoids redundant work when buckets have identical current sums
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 5: Return the result**
|
||||||
|
|
||||||
|
- If backtracking explores all options without success, return `false`
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Missing the Divisibility Check
|
||||||
|
description: |
|
||||||
|
If `total_sum % k != 0`, it's mathematically impossible to partition into `k` equal subsets. Always check this first to avoid unnecessary computation.
|
||||||
|
|
||||||
|
For example, `nums = [1,2,3,4]` with `k = 3` has sum `10`, which isn't divisible by `3`.
|
||||||
|
wrong_approach: "Starting backtracking without checking divisibility"
|
||||||
|
correct_approach: "Return false immediately if total_sum % k != 0"
|
||||||
|
|
||||||
|
- title: Not Sorting in Descending Order
|
||||||
|
description: |
|
||||||
|
Without sorting, the backtracking explores many dead-end paths before failing. By placing larger numbers first, we fail faster when a number can't fit.
|
||||||
|
|
||||||
|
Consider `nums = [1,1,1,1,1,1,10]` with `k = 2` and `target = 8`. Without sorting, we might try many combinations of 1s before realising the `10` can never fit. Sorting puts `10` first, causing immediate failure.
|
||||||
|
wrong_approach: "Backtracking on unsorted array"
|
||||||
|
correct_approach: "Sort descending to prune earlier"
|
||||||
|
|
||||||
|
- title: Not Skipping Duplicate Bucket States
|
||||||
|
description: |
|
||||||
|
If two buckets have the same current sum, placing a number in either leads to equivalent configurations. Without this optimisation, the algorithm does redundant work.
|
||||||
|
|
||||||
|
For example, with `buckets = [3, 3, 0, 0]` and trying to place `2`, placing it in bucket 0 vs bucket 1 creates the same branching structure. Skip one to avoid duplication.
|
||||||
|
wrong_approach: "Trying every bucket regardless of current sums"
|
||||||
|
correct_approach: "Skip bucket i if buckets[i] == buckets[i-1]"
|
||||||
|
|
||||||
|
- title: Forgetting the Single Large Element Check
|
||||||
|
description: |
|
||||||
|
If any element is larger than `target`, it can never fit in any bucket. Check this after calculating the target to fail fast.
|
||||||
|
wrong_approach: "Letting backtracking discover this through exhaustion"
|
||||||
|
correct_approach: "Check max(nums) <= target before backtracking"
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Backtracking pattern**: When exploring combinations, try each option, recurse, then undo (backtrack) if it doesn't lead to a solution"
|
||||||
|
- "**Pruning is essential**: Sorting descending and skipping duplicate states transform an unusable algorithm into a practical one"
|
||||||
|
- "**Mathematical pre-checks**: Always validate constraints (divisibility, max element) before starting expensive computation"
|
||||||
|
- "**Small constraints hint at approach**: `n <= 16` suggests exponential algorithms like backtracking or bitmask DP are acceptable"
|
||||||
|
|
||||||
|
time_complexity: "O(k * 2^n). In the worst case, we explore all possible subsets for each of the k buckets. Pruning significantly reduces this in practice."
|
||||||
|
space_complexity: "O(n). We use O(n) for the recursion stack depth and O(k) for the buckets array."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Backtracking with Pruning
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def can_partition_k_subsets(nums: list[int], k: int) -> bool:
|
||||||
|
total = sum(nums)
|
||||||
|
|
||||||
|
# If total isn't divisible by k, equal partition is impossible
|
||||||
|
if total % k != 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
target = total // k
|
||||||
|
|
||||||
|
# If any element exceeds target, it can't fit in any bucket
|
||||||
|
if max(nums) > target:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Sort descending to fail faster (large numbers have fewer options)
|
||||||
|
nums.sort(reverse=True)
|
||||||
|
|
||||||
|
# k buckets, each starting empty
|
||||||
|
buckets = [0] * k
|
||||||
|
|
||||||
|
def backtrack(index: int) -> bool:
|
||||||
|
# All numbers placed successfully
|
||||||
|
if index == len(nums):
|
||||||
|
return True
|
||||||
|
|
||||||
|
num = nums[index]
|
||||||
|
|
||||||
|
for i in range(k):
|
||||||
|
# Skip duplicate bucket states to avoid redundant work
|
||||||
|
if i > 0 and buckets[i] == buckets[i - 1]:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Try placing num in bucket i
|
||||||
|
if buckets[i] + num <= target:
|
||||||
|
buckets[i] += num
|
||||||
|
|
||||||
|
# Recurse to place next number
|
||||||
|
if backtrack(index + 1):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Backtrack: remove num from bucket
|
||||||
|
buckets[i] -= num
|
||||||
|
|
||||||
|
# No valid placement found for this number
|
||||||
|
return False
|
||||||
|
|
||||||
|
return backtrack(0)
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(k * 2^n) worst case — we may explore all subset combinations. Pruning makes this much faster in practice.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n) — recursion stack depth plus O(k) for buckets array.
|
||||||
|
|
||||||
|
We try to fill k buckets by placing each number (largest first) into a valid bucket. Key optimisations: sorting descending to fail fast, skipping duplicate bucket states to avoid redundant paths, and early termination checks.
|
||||||
|
|
||||||
|
- approach_name: Bitmask Dynamic Programming
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def can_partition_k_subsets(nums: list[int], k: int) -> bool:
|
||||||
|
total = sum(nums)
|
||||||
|
|
||||||
|
if total % k != 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
target = total // k
|
||||||
|
n = len(nums)
|
||||||
|
|
||||||
|
# dp[mask] = remaining sum needed for current bucket
|
||||||
|
# -1 means this state is unreachable
|
||||||
|
dp = [-1] * (1 << n)
|
||||||
|
dp[0] = 0 # Empty set has 0 sum
|
||||||
|
|
||||||
|
for mask in range(1 << n):
|
||||||
|
if dp[mask] == -1:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for i in range(n):
|
||||||
|
# If element i is not yet used
|
||||||
|
if mask & (1 << i) == 0:
|
||||||
|
# Current bucket sum after adding nums[i]
|
||||||
|
new_sum = dp[mask] + nums[i]
|
||||||
|
|
||||||
|
if new_sum <= target:
|
||||||
|
new_mask = mask | (1 << i)
|
||||||
|
# Mod by target: when bucket fills, start new one
|
||||||
|
dp[new_mask] = new_sum % target
|
||||||
|
|
||||||
|
# All elements used, last bucket exactly filled
|
||||||
|
return dp[(1 << n) - 1] == 0
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n * 2^n) — we iterate through all 2^n subsets and for each check n elements.
|
||||||
|
|
||||||
|
**Space Complexity:** O(2^n) — the dp array stores state for each possible subset.
|
||||||
|
|
||||||
|
This approach uses bitmask DP where each bit represents whether an element is used. We track the "remaining space" in the current bucket. When a bucket fills (`sum == target`), we start fresh with the next bucket (achieved via `% target`). This guarantees we consider all valid partitions systematically.
|
||||||
255
backend/data/questions/path-with-minimum-effort.yaml
Normal file
255
backend/data/questions/path-with-minimum-effort.yaml
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
title: Path With Minimum Effort
|
||||||
|
slug: path-with-minimum-effort
|
||||||
|
difficulty: medium
|
||||||
|
leetcode_id: 1631
|
||||||
|
leetcode_url: https://leetcode.com/problems/path-with-minimum-effort/
|
||||||
|
categories:
|
||||||
|
- graphs
|
||||||
|
- arrays
|
||||||
|
- binary-search
|
||||||
|
- heap
|
||||||
|
patterns:
|
||||||
|
- binary-search
|
||||||
|
- bfs
|
||||||
|
- heap
|
||||||
|
- matrix-traversal
|
||||||
|
|
||||||
|
description: |
|
||||||
|
You are a hiker preparing for an upcoming hike. You are given `heights`, a 2D array of size `rows x columns`, where `heights[row][col]` represents the height of cell `(row, col)`. You are situated in the top-left cell, `(0, 0)`, and you hope to travel to the bottom-right cell, `(rows-1, columns-1)` (i.e., **0-indexed**). You can move **up**, **down**, **left**, or **right**, and you wish to find a route that requires the minimum **effort**.
|
||||||
|
|
||||||
|
A route's **effort** is the **maximum absolute difference** in heights between two consecutive cells of the route.
|
||||||
|
|
||||||
|
Return *the minimum **effort** required to travel from the top-left cell to the bottom-right cell*.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `rows == heights.length`
|
||||||
|
- `columns == heights[i].length`
|
||||||
|
- `1 <= rows, columns <= 100`
|
||||||
|
- `1 <= heights[i][j] <= 10^6`
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: "heights = [[1,2,2],[3,8,2],[5,3,5]]"
|
||||||
|
output: "2"
|
||||||
|
explanation: "The route of [1,3,5,3,5] has a maximum absolute difference of 2 in consecutive cells. This is better than the route of [1,2,2,2,5], where the maximum absolute difference is 3."
|
||||||
|
- input: "heights = [[1,2,3],[3,8,4],[5,3,5]]"
|
||||||
|
output: "1"
|
||||||
|
explanation: "The route of [1,2,3,4,5] has a maximum absolute difference of 1 in consecutive cells, which is better than route [1,3,5,3,5]."
|
||||||
|
- input: "heights = [[1,2,1,1,1],[1,2,1,2,1],[1,2,1,2,1],[1,2,1,2,1],[1,1,1,2,1]]"
|
||||||
|
output: "0"
|
||||||
|
explanation: "This route does not require any effort."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Imagine hiking through a mountainous terrain represented as a grid. At each step, you feel the "effort" of climbing up or down — the bigger the height difference, the harder it is. Your goal is to find a path from the top-left corner to the bottom-right corner that minimises the **worst single step** along the way.
|
||||||
|
|
||||||
|
The key insight is that we're not minimising the *total* effort (sum of all differences), but rather the *maximum* effort in any single step. Think of it like this: if your worst step has a height difference of 5, that defines your path's difficulty — even if every other step is easy.
|
||||||
|
|
||||||
|
This "minimax" objective suggests a different approach from typical shortest path problems. We have two elegant strategies:
|
||||||
|
|
||||||
|
1. **Binary search on the answer**: What if we could ask, "Can I reach the destination if my maximum allowed effort is `k`?" If yes, try a smaller `k`. If no, we need a larger `k`. This transforms the optimisation problem into a series of yes/no reachability checks.
|
||||||
|
|
||||||
|
2. **Modified Dijkstra's algorithm**: Instead of minimising total distance, we track the minimum "maximum effort so far" to reach each cell. Using a min-heap prioritised by effort, we always expand the most promising path first.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We present two approaches: **Binary Search + BFS** and **Dijkstra's Algorithm**.
|
||||||
|
|
||||||
|
**Approach A: Binary Search + BFS**
|
||||||
|
|
||||||
|
**Step 1: Define the search space**
|
||||||
|
|
||||||
|
- The minimum possible effort is `0` (all cells have the same height)
|
||||||
|
- The maximum possible effort is `10^6 - 1` (maximum height difference based on constraints)
|
||||||
|
- We binary search on this range to find the minimum effort that allows us to reach the destination
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Implement the feasibility check**
|
||||||
|
|
||||||
|
- Given a maximum allowed effort `k`, use BFS to check if we can reach `(rows-1, cols-1)` from `(0, 0)`
|
||||||
|
- Only traverse edges where the height difference is `<= k`
|
||||||
|
- If we reach the destination, the answer is `k` or lower; otherwise, we need a larger effort
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Binary search for the answer**
|
||||||
|
|
||||||
|
- If `can_reach(mid)` is true, search the lower half (`right = mid`)
|
||||||
|
- If false, search the upper half (`left = mid + 1`)
|
||||||
|
- Return `left` when the search converges
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Approach B: Dijkstra's Algorithm**
|
||||||
|
|
||||||
|
**Step 1: Initialise data structures**
|
||||||
|
|
||||||
|
- `effort`: 2D array tracking the minimum effort to reach each cell (initialised to infinity)
|
||||||
|
- `effort[0][0] = 0`: Starting cell requires zero effort
|
||||||
|
- Min-heap: `[(0, 0, 0)]` representing `(current_max_effort, row, col)`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Process cells greedily**
|
||||||
|
|
||||||
|
- Pop the cell with the smallest effort from the heap
|
||||||
|
- If we've reached `(rows-1, cols-1)`, return the effort immediately
|
||||||
|
- For each neighbour, calculate the new effort: `max(current_effort, |height_diff|)`
|
||||||
|
- If this is better than the previously recorded effort for that neighbour, update and add to heap
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Return the result**
|
||||||
|
|
||||||
|
- The first time we pop the destination cell, we have found the minimum effort
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Dijkstra's works here because we're using a min-heap: we always process the path with the smallest maximum effort first, guaranteeing optimality.
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Using Standard BFS/DFS Alone
|
||||||
|
description: |
|
||||||
|
Standard BFS finds the shortest path by number of edges, and DFS explores deeply first. Neither directly minimises the maximum edge weight along a path.
|
||||||
|
|
||||||
|
BFS/DFS can be used as a subroutine (to check reachability given a constraint), but you need an outer structure (binary search or Dijkstra's priority queue) to find the optimal answer.
|
||||||
|
wrong_approach: "Plain BFS/DFS to find path with minimum effort"
|
||||||
|
correct_approach: "Binary Search + BFS or Dijkstra's algorithm"
|
||||||
|
|
||||||
|
- title: Minimising Total Effort Instead of Maximum
|
||||||
|
description: |
|
||||||
|
This problem asks for the **minimum of the maximum** effort along any path — not the sum of all efforts. This is a minimax problem.
|
||||||
|
|
||||||
|
For example, a path with steps [3, 1, 1, 1] has effort 3 (the max), while [2, 2, 2, 2] has effort 2 — even though the latter has a higher sum.
|
||||||
|
wrong_approach: "Summing height differences along the path"
|
||||||
|
correct_approach: "Tracking maximum height difference along each path"
|
||||||
|
|
||||||
|
- title: Not Using a Min-Heap in Dijkstra's
|
||||||
|
description: |
|
||||||
|
Without a min-heap, you lose the greedy property that makes Dijkstra's work. You might process a cell via a suboptimal path first, then need to reprocess it later.
|
||||||
|
|
||||||
|
The min-heap ensures we always expand the path with the smallest maximum effort, so the first time we reach the destination is guaranteed to be optimal.
|
||||||
|
wrong_approach: "Using a regular queue or processing in arbitrary order"
|
||||||
|
correct_approach: "Use heapq with (effort, row, col) tuples"
|
||||||
|
|
||||||
|
- title: Forgetting to Check All Four Directions
|
||||||
|
description: |
|
||||||
|
Unlike some grid problems, this one allows movement in all four directions (up, down, left, right). Missing a direction means potentially missing the optimal path.
|
||||||
|
|
||||||
|
Always iterate over all four directions: `[(0,1), (0,-1), (1,0), (-1,0)]`.
|
||||||
|
wrong_approach: "Only moving right and down"
|
||||||
|
correct_approach: "Exploring all four orthogonal directions"
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Minimax problems**: When minimising the maximum cost, binary search on the answer or modified Dijkstra's are powerful techniques"
|
||||||
|
- "**Binary search on answer space**: Transform 'find minimum X' into 'is X achievable?' and binary search"
|
||||||
|
- "**Modified Dijkstra's**: Works for any problem where you need the 'best' path under a monotonic metric — not just shortest distance"
|
||||||
|
- "**Grid as graph**: Cells are nodes, adjacent cells are edges with weight equal to height difference"
|
||||||
|
|
||||||
|
time_complexity: "O(m × n × log(maxHeight)) for binary search approach, O(m × n × log(m × n)) for Dijkstra's. Both are efficient for the given constraints."
|
||||||
|
space_complexity: "O(m × n). We need space for the visited/effort array and the BFS queue or Dijkstra's heap."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Dijkstra's Algorithm
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
import heapq
|
||||||
|
|
||||||
|
def minimum_effort_path(heights: list[list[int]]) -> int:
|
||||||
|
rows, cols = len(heights), len(heights[0])
|
||||||
|
|
||||||
|
# effort[r][c] = minimum effort to reach cell (r, c)
|
||||||
|
effort = [[float('inf')] * cols for _ in range(rows)]
|
||||||
|
effort[0][0] = 0
|
||||||
|
|
||||||
|
# Min-heap: (current_max_effort, row, col)
|
||||||
|
heap = [(0, 0, 0)]
|
||||||
|
|
||||||
|
directions = [(0, 1), (0, -1), (1, 0), (-1, 0)]
|
||||||
|
|
||||||
|
while heap:
|
||||||
|
curr_effort, r, c = heapq.heappop(heap)
|
||||||
|
|
||||||
|
# Found the destination - return immediately
|
||||||
|
if r == rows - 1 and c == cols - 1:
|
||||||
|
return curr_effort
|
||||||
|
|
||||||
|
# Skip if we've found a better path to this cell
|
||||||
|
if curr_effort > effort[r][c]:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Explore all four neighbours
|
||||||
|
for dr, dc in directions:
|
||||||
|
nr, nc = r + dr, c + dc
|
||||||
|
|
||||||
|
if 0 <= nr < rows and 0 <= nc < cols:
|
||||||
|
# Effort to reach neighbour = max of current effort and this edge
|
||||||
|
height_diff = abs(heights[nr][nc] - heights[r][c])
|
||||||
|
new_effort = max(curr_effort, height_diff)
|
||||||
|
|
||||||
|
# Only update if we found a better path
|
||||||
|
if new_effort < effort[nr][nc]:
|
||||||
|
effort[nr][nc] = new_effort
|
||||||
|
heapq.heappush(heap, (new_effort, nr, nc))
|
||||||
|
|
||||||
|
return effort[rows - 1][cols - 1]
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(m × n × log(m × n)) — Each cell can be added to the heap multiple times, but we process at most O(m × n) pops. Each heap operation is O(log(m × n)).
|
||||||
|
|
||||||
|
**Space Complexity:** O(m × n) — For the effort array and heap.
|
||||||
|
|
||||||
|
We use a modified Dijkstra's algorithm where instead of summing edge weights, we track the maximum edge weight along the path. The min-heap ensures we always process the most promising path first.
|
||||||
|
|
||||||
|
- approach_name: Binary Search + BFS
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
def minimum_effort_path(heights: list[list[int]]) -> int:
|
||||||
|
rows, cols = len(heights), len(heights[0])
|
||||||
|
directions = [(0, 1), (0, -1), (1, 0), (-1, 0)]
|
||||||
|
|
||||||
|
def can_reach(max_effort: int) -> bool:
|
||||||
|
"""Check if we can reach destination with at most max_effort."""
|
||||||
|
if max_effort < 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
visited = [[False] * cols for _ in range(rows)]
|
||||||
|
queue = deque([(0, 0)])
|
||||||
|
visited[0][0] = True
|
||||||
|
|
||||||
|
while queue:
|
||||||
|
r, c = queue.popleft()
|
||||||
|
|
||||||
|
# Reached the destination
|
||||||
|
if r == rows - 1 and c == cols - 1:
|
||||||
|
return True
|
||||||
|
|
||||||
|
for dr, dc in directions:
|
||||||
|
nr, nc = r + dr, c + dc
|
||||||
|
|
||||||
|
if 0 <= nr < rows and 0 <= nc < cols and not visited[nr][nc]:
|
||||||
|
# Only traverse if height diff is within allowed effort
|
||||||
|
if abs(heights[nr][nc] - heights[r][c]) <= max_effort:
|
||||||
|
visited[nr][nc] = True
|
||||||
|
queue.append((nr, nc))
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Binary search on the answer
|
||||||
|
left, right = 0, 10**6 - 1
|
||||||
|
|
||||||
|
while left < right:
|
||||||
|
mid = (left + right) // 2
|
||||||
|
|
||||||
|
if can_reach(mid):
|
||||||
|
right = mid # Try smaller effort
|
||||||
|
else:
|
||||||
|
left = mid + 1 # Need more effort
|
||||||
|
|
||||||
|
return left
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(m × n × log(maxHeight)) — Binary search over the effort range (log(10^6) iterations), each requiring O(m × n) BFS.
|
||||||
|
|
||||||
|
**Space Complexity:** O(m × n) — For the visited array and BFS queue.
|
||||||
|
|
||||||
|
We binary search on the answer: for each candidate effort `k`, BFS checks if we can reach the destination using only edges with height difference ≤ `k`. If yes, we try smaller; if no, we try larger.
|
||||||
222
backend/data/questions/perfect-squares.yaml
Normal file
222
backend/data/questions/perfect-squares.yaml
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
title: Perfect Squares
|
||||||
|
slug: perfect-squares
|
||||||
|
difficulty: medium
|
||||||
|
leetcode_id: 279
|
||||||
|
leetcode_url: https://leetcode.com/problems/perfect-squares/
|
||||||
|
categories:
|
||||||
|
- dynamic-programming
|
||||||
|
- math
|
||||||
|
patterns:
|
||||||
|
- dynamic-programming
|
||||||
|
- bfs
|
||||||
|
|
||||||
|
description: |
|
||||||
|
Given an integer `n`, return *the least number of perfect square numbers that sum to* `n`.
|
||||||
|
|
||||||
|
A **perfect square** is an integer that is the square of an integer; in other words, it is the product of some integer with itself. For example, `1`, `4`, `9`, and `16` are perfect squares while `3` and `11` are not.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `1 <= n <= 10^4`
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: "n = 12"
|
||||||
|
output: "3"
|
||||||
|
explanation: "12 = 4 + 4 + 4."
|
||||||
|
- input: "n = 13"
|
||||||
|
output: "2"
|
||||||
|
explanation: "13 = 4 + 9."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Imagine you have a collection of coins, but instead of standard denominations, your coins have values `1`, `4`, `9`, `16`, `25`, ... (all perfect squares). Your goal is to make change for amount `n` using the **fewest coins possible**.
|
||||||
|
|
||||||
|
This reframing reveals the problem's structure: it's essentially the classic **Coin Change** problem where the coin denominations are perfect squares up to `n`.
|
||||||
|
|
||||||
|
Think of it like this: for any number `n`, you can always represent it as a sum of `1`s (since `1` is a perfect square), so a solution always exists. The challenge is finding the **minimum** number of squares needed. For example, while `12 = 1 + 1 + 1 + ... + 1` works (12 ones), the optimal solution is `12 = 4 + 4 + 4` (just 3 squares).
|
||||||
|
|
||||||
|
The key insight is that the answer for `n` depends on answers for smaller numbers. If we use a perfect square `k^2` as part of our solution, then we need `1 + numSquares(n - k^2)` total squares. By trying all valid perfect squares and taking the minimum, we build up the optimal solution.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using **Dynamic Programming (Bottom-Up)**:
|
||||||
|
|
||||||
|
**Step 1: Precompute perfect squares**
|
||||||
|
|
||||||
|
- Generate all perfect squares up to `n`: `[1, 4, 9, 16, ...]` where each value `<= n`
|
||||||
|
- These are our "coins" for making change
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Initialise the DP array**
|
||||||
|
|
||||||
|
- Create array `dp` of size `n + 1`
|
||||||
|
- `dp[i]`: the minimum number of perfect squares that sum to `i`
|
||||||
|
- `dp[0] = 0`: zero squares needed to sum to zero
|
||||||
|
- Initialise all other values to infinity (or `n + 1`) as a placeholder for "not yet computed"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Fill the DP array**
|
||||||
|
|
||||||
|
- For each value `i` from `1` to `n`:
|
||||||
|
- Try subtracting each perfect square `sq` where `sq <= i`
|
||||||
|
- `dp[i] = min(dp[i], dp[i - sq] + 1)`
|
||||||
|
- The `+ 1` accounts for using one square of value `sq`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 4: Return the result**
|
||||||
|
|
||||||
|
- Return `dp[n]`, which contains the minimum squares needed for our target
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
This bottom-up approach ensures we solve smaller subproblems first, building up to the final answer. Each state depends only on previously computed states.
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Greedy Doesn't Work
|
||||||
|
description: |
|
||||||
|
A tempting approach is to greedily pick the largest perfect square that fits, then recurse on the remainder.
|
||||||
|
|
||||||
|
For example, with `n = 12`:
|
||||||
|
- Greedy picks `9` (largest square <= 12), leaving `3`
|
||||||
|
- Then picks `1` three times for the remaining `3`
|
||||||
|
- Result: `9 + 1 + 1 + 1 = 12` using **4 squares**
|
||||||
|
|
||||||
|
But the optimal is `4 + 4 + 4 = 12` using only **3 squares**.
|
||||||
|
|
||||||
|
Greedy fails because locally optimal choices don't guarantee global optimum in this problem.
|
||||||
|
wrong_approach: "Always pick the largest perfect square"
|
||||||
|
correct_approach: "Use DP to explore all combinations"
|
||||||
|
|
||||||
|
- title: Forgetting Base Case
|
||||||
|
description: |
|
||||||
|
The base case `dp[0] = 0` is essential. Without it, the recurrence relation breaks.
|
||||||
|
|
||||||
|
When we compute `dp[4]` and try square `4`: we need `dp[4 - 4] = dp[0]`. If `dp[0]` isn't properly initialised to `0`, we get incorrect results.
|
||||||
|
wrong_approach: "Initialise dp[0] to 1 or leave undefined"
|
||||||
|
correct_approach: "Set dp[0] = 0 explicitly"
|
||||||
|
|
||||||
|
- title: Inefficient Square Generation
|
||||||
|
description: |
|
||||||
|
Regenerating perfect squares inside the inner loop wastes time.
|
||||||
|
|
||||||
|
Instead, precompute all perfect squares up to `n` once before the main loop. This reduces redundant computation and improves cache efficiency.
|
||||||
|
wrong_approach: "Generate squares inside nested loops"
|
||||||
|
correct_approach: "Precompute squares array once"
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Unbounded knapsack pattern**: This problem is structurally identical to Coin Change — perfect squares are the denominations, and we minimise the count"
|
||||||
|
- "**Greedy fails**: Problems asking for minimum counts over combinations often require DP because greedy local choices don't guarantee global optimum"
|
||||||
|
- "**Bottom-up DP**: Building solutions from smaller subproblems (`dp[0]` to `dp[n]`) avoids recursion overhead and stack limits"
|
||||||
|
- "**BFS alternative**: This can also be solved with BFS, treating each number as a node and perfect squares as edges — the first path to reach `n` is shortest"
|
||||||
|
|
||||||
|
time_complexity: "O(n * sqrt(n)). For each value from `1` to `n`, we try up to `sqrt(n)` perfect squares."
|
||||||
|
space_complexity: "O(n). We use a DP array of size `n + 1` to store intermediate results."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Dynamic Programming (Bottom-Up)
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def num_squares(n: int) -> int:
|
||||||
|
# Precompute all perfect squares up to n
|
||||||
|
squares = []
|
||||||
|
i = 1
|
||||||
|
while i * i <= n:
|
||||||
|
squares.append(i * i)
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# dp[i] = minimum squares needed to sum to i
|
||||||
|
dp = [float('inf')] * (n + 1)
|
||||||
|
dp[0] = 0 # Base case: 0 squares needed for sum of 0
|
||||||
|
|
||||||
|
# Build up solutions from 1 to n
|
||||||
|
for target in range(1, n + 1):
|
||||||
|
# Try each perfect square as the last square used
|
||||||
|
for sq in squares:
|
||||||
|
if sq > target:
|
||||||
|
break
|
||||||
|
# Use one square of value sq, plus optimal for remainder
|
||||||
|
dp[target] = min(dp[target], dp[target - sq] + 1)
|
||||||
|
|
||||||
|
return dp[n]
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n * sqrt(n)) — For each of `n` values, we iterate through up to `sqrt(n)` perfect squares.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n) — The DP array stores results for all values from `0` to `n`.
|
||||||
|
|
||||||
|
This bottom-up approach fills in the DP table iteratively. For each target sum, we consider all valid perfect squares and take the minimum. The precomputed squares list ensures we don't regenerate squares repeatedly.
|
||||||
|
|
||||||
|
- approach_name: BFS (Shortest Path)
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
def num_squares(n: int) -> int:
|
||||||
|
# Precompute perfect squares up to n
|
||||||
|
squares = []
|
||||||
|
i = 1
|
||||||
|
while i * i <= n:
|
||||||
|
squares.append(i * i)
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# BFS: each level represents adding one more square
|
||||||
|
queue = deque([n])
|
||||||
|
visited = {n}
|
||||||
|
level = 0
|
||||||
|
|
||||||
|
while queue:
|
||||||
|
level += 1
|
||||||
|
# Process all nodes at current level
|
||||||
|
for _ in range(len(queue)):
|
||||||
|
curr = queue.popleft()
|
||||||
|
|
||||||
|
# Try subtracting each perfect square
|
||||||
|
for sq in squares:
|
||||||
|
remainder = curr - sq
|
||||||
|
if remainder == 0:
|
||||||
|
return level # Found shortest path
|
||||||
|
if remainder > 0 and remainder not in visited:
|
||||||
|
visited.add(remainder)
|
||||||
|
queue.append(remainder)
|
||||||
|
|
||||||
|
return level # Should never reach here for valid input
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n * sqrt(n)) — In the worst case, we visit each number from `1` to `n` and try `sqrt(n)` squares.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n) — The visited set and queue can hold up to `n` elements.
|
||||||
|
|
||||||
|
BFS treats this as a shortest path problem. Start from `n`, and at each step subtract a perfect square. The first time we reach `0`, the current level (depth) is the minimum number of squares. BFS guarantees the shortest path in an unweighted graph.
|
||||||
|
|
||||||
|
- approach_name: Top-Down DP with Memoisation
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
def num_squares(n: int) -> int:
|
||||||
|
# Precompute perfect squares up to n
|
||||||
|
squares = []
|
||||||
|
i = 1
|
||||||
|
while i * i <= n:
|
||||||
|
squares.append(i * i)
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
@lru_cache(maxsize=None)
|
||||||
|
def dp(target: int) -> int:
|
||||||
|
if target == 0:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
min_count = float('inf')
|
||||||
|
for sq in squares:
|
||||||
|
if sq > target:
|
||||||
|
break
|
||||||
|
min_count = min(min_count, dp(target - sq) + 1)
|
||||||
|
|
||||||
|
return min_count
|
||||||
|
|
||||||
|
return dp(n)
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n * sqrt(n)) — Same as bottom-up, but with recursion overhead.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n) — Memoisation cache plus recursion stack depth.
|
||||||
|
|
||||||
|
This recursive approach with memoisation is conceptually simpler but has function call overhead. The `@lru_cache` decorator handles memoisation automatically. While correct, bottom-up DP is typically faster in practice for this problem.
|
||||||
241
backend/data/questions/permutation-in-string.yaml
Normal file
241
backend/data/questions/permutation-in-string.yaml
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
title: Permutation in String
|
||||||
|
slug: permutation-in-string
|
||||||
|
difficulty: medium
|
||||||
|
leetcode_id: 567
|
||||||
|
leetcode_url: https://leetcode.com/problems/permutation-in-string/
|
||||||
|
categories:
|
||||||
|
- strings
|
||||||
|
- hash-tables
|
||||||
|
- two-pointers
|
||||||
|
patterns:
|
||||||
|
- sliding-window
|
||||||
|
|
||||||
|
description: |
|
||||||
|
Given two strings `s1` and `s2`, return `true` if `s2` contains a permutation of `s1`, or `false` otherwise.
|
||||||
|
|
||||||
|
In other words, return `true` if one of `s1`'s permutations is the substring of `s2`.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `1 <= s1.length, s2.length <= 10^4`
|
||||||
|
- `s1` and `s2` consist of lowercase English letters.
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: 's1 = "ab", s2 = "eidbaooo"'
|
||||||
|
output: "true"
|
||||||
|
explanation: "s2 contains one permutation of s1 (\"ba\")."
|
||||||
|
- input: 's1 = "ab", s2 = "eidboaoo"'
|
||||||
|
output: "false"
|
||||||
|
explanation: "No permutation of s1 exists as a contiguous substring in s2."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Think of this problem as searching for an **anagram** of `s1` hidden somewhere within `s2`.
|
||||||
|
|
||||||
|
A permutation of a string is simply a rearrangement of its characters — which means any permutation has **exactly the same character frequencies** as the original. For example, "ab", "ba" are both permutations of each other because they both contain one 'a' and one 'b'.
|
||||||
|
|
||||||
|
The key insight is that we don't need to generate all permutations of `s1` (which would be factorial in complexity). Instead, we can slide a **window of size `len(s1)`** across `s2` and check if the characters in that window match the character frequencies of `s1`.
|
||||||
|
|
||||||
|
Imagine you have a magnifying glass the exact width of `s1`. As you slide it across `s2` one character at a time, you're checking: "Do the characters under my magnifying glass form an anagram of `s1`?"
|
||||||
|
|
||||||
|
This transforms the problem from "find any permutation" to "find a window with matching character counts" — a classic sliding window pattern.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using a **Fixed-Size Sliding Window** with character frequency counting:
|
||||||
|
|
||||||
|
**Step 1: Handle edge cases**
|
||||||
|
|
||||||
|
- If `s1` is longer than `s2`, it's impossible for `s2` to contain any permutation of `s1` — return `false` immediately
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Build the frequency map for s1**
|
||||||
|
|
||||||
|
- Count the frequency of each character in `s1`
|
||||||
|
- This is our "target" that we want to match
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Initialise the sliding window**
|
||||||
|
|
||||||
|
- Create a frequency map for the first `len(s1)` characters of `s2`
|
||||||
|
- This is our initial window
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 4: Check initial window**
|
||||||
|
|
||||||
|
- If the window's frequency map matches `s1`'s frequency map, we found a permutation — return `true`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 5: Slide the window across s2**
|
||||||
|
|
||||||
|
- For each new position, add the incoming character (right side) to the window
|
||||||
|
- Remove the outgoing character (left side) from the window
|
||||||
|
- If a character's count drops to zero, remove it from the map entirely (for clean comparison)
|
||||||
|
- Compare the window's frequency map with `s1`'s — if they match, return `true`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 6: Return the result**
|
||||||
|
|
||||||
|
- If no matching window is found after sliding through all of `s2`, return `false`
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Generating All Permutations
|
||||||
|
description: |
|
||||||
|
A naive approach might try to generate all permutations of `s1` and check if any exists in `s2`.
|
||||||
|
|
||||||
|
For a string of length `n`, there are `n!` (factorial) permutations. With `s1.length <= 10^4`, this would mean up to `10000!` permutations — an astronomically large number that's computationally impossible.
|
||||||
|
|
||||||
|
The sliding window approach avoids this entirely by recognising that **character frequency equality implies permutation**.
|
||||||
|
wrong_approach: "Generate all permutations of s1 and search for each"
|
||||||
|
correct_approach: "Compare character frequencies using sliding window"
|
||||||
|
|
||||||
|
- title: Comparing Strings Instead of Frequencies
|
||||||
|
description: |
|
||||||
|
Sorting each window and comparing to sorted `s1` works but is inefficient.
|
||||||
|
|
||||||
|
Sorting a window of size `k` takes O(k log k). Doing this for each of the `n - k + 1` windows gives O(n * k log k) overall. For large inputs, this is too slow.
|
||||||
|
|
||||||
|
Using hash maps for frequency comparison gives O(1) comparison per window slide (amortised), resulting in O(n) total time.
|
||||||
|
wrong_approach: "Sort each window and compare to sorted s1"
|
||||||
|
correct_approach: "Use hash maps to track and compare character frequencies"
|
||||||
|
|
||||||
|
- title: Not Cleaning Up Zero Counts
|
||||||
|
description: |
|
||||||
|
When a character's count reaches zero in the window map, failing to remove it can break map equality comparisons.
|
||||||
|
|
||||||
|
For example, `{'a': 1, 'b': 0}` is not equal to `{'a': 1}` in most implementations, even though they represent the same character set.
|
||||||
|
|
||||||
|
Always remove characters from the map when their count reaches zero.
|
||||||
|
|
||||||
|
- title: Off-by-One Errors in Window Boundaries
|
||||||
|
description: |
|
||||||
|
The window size must be exactly `len(s1)`. Common mistakes include:
|
||||||
|
- Starting the slide from index 0 instead of `len(s1)`
|
||||||
|
- Removing the wrong character when sliding (should remove `s2[i - len(s1)]`)
|
||||||
|
|
||||||
|
Trace through a small example manually to verify your indices.
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Permutation = same character frequencies**: Recognising this transforms the problem from combinatorial to linear"
|
||||||
|
- "**Fixed-size sliding window**: When searching for a pattern of known length, use a window of that exact size"
|
||||||
|
- "**Hash map comparison**: Comparing character counts is more efficient than generating/sorting permutations"
|
||||||
|
- "**Pattern recognition**: This problem is nearly identical to *Find All Anagrams in a String* (LeetCode 438) — same technique, different return type"
|
||||||
|
|
||||||
|
time_complexity: "O(n). We traverse `s2` once, and each character is added to and removed from the window exactly once. Hash map operations are O(1) amortised."
|
||||||
|
space_complexity: "O(1). The frequency maps store at most 26 entries (lowercase English letters), which is constant regardless of input size."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Sliding Window with Hash Map
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
def check_inclusion(s1: str, s2: str) -> bool:
|
||||||
|
# Edge case: s1 longer than s2
|
||||||
|
if len(s1) > len(s2):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Build frequency map for s1 (our target)
|
||||||
|
s1_count = Counter(s1)
|
||||||
|
window_size = len(s1)
|
||||||
|
|
||||||
|
# Build frequency map for initial window in s2
|
||||||
|
window_count = Counter(s2[:window_size])
|
||||||
|
|
||||||
|
# Check if initial window matches
|
||||||
|
if window_count == s1_count:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Slide the window across s2
|
||||||
|
for i in range(window_size, len(s2)):
|
||||||
|
# Add incoming character (right side of window)
|
||||||
|
window_count[s2[i]] += 1
|
||||||
|
|
||||||
|
# Remove outgoing character (left side of window)
|
||||||
|
left_char = s2[i - window_size]
|
||||||
|
window_count[left_char] -= 1
|
||||||
|
|
||||||
|
# Clean up zero counts for proper comparison
|
||||||
|
if window_count[left_char] == 0:
|
||||||
|
del window_count[left_char]
|
||||||
|
|
||||||
|
# Check if current window matches s1
|
||||||
|
if window_count == s1_count:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — We iterate through `s2` once, with O(1) operations per step.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — The hash maps contain at most 26 keys (one per lowercase letter).
|
||||||
|
|
||||||
|
The `Counter` class from Python's collections module provides a clean way to count character frequencies. Comparing two `Counter` objects with `==` checks if they have the same keys with the same values.
|
||||||
|
|
||||||
|
- approach_name: Sliding Window with Array (Optimised)
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def check_inclusion(s1: str, s2: str) -> bool:
|
||||||
|
if len(s1) > len(s2):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Use arrays instead of hash maps (26 lowercase letters)
|
||||||
|
s1_count = [0] * 26
|
||||||
|
window_count = [0] * 26
|
||||||
|
|
||||||
|
# Build frequency array for s1
|
||||||
|
for c in s1:
|
||||||
|
s1_count[ord(c) - ord('a')] += 1
|
||||||
|
|
||||||
|
# Build frequency array for initial window
|
||||||
|
for i in range(len(s1)):
|
||||||
|
window_count[ord(s2[i]) - ord('a')] += 1
|
||||||
|
|
||||||
|
# Check initial window
|
||||||
|
if window_count == s1_count:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Slide the window
|
||||||
|
for i in range(len(s1), len(s2)):
|
||||||
|
# Add incoming character
|
||||||
|
window_count[ord(s2[i]) - ord('a')] += 1
|
||||||
|
# Remove outgoing character
|
||||||
|
window_count[ord(s2[i - len(s1)]) - ord('a')] -= 1
|
||||||
|
|
||||||
|
if window_count == s1_count:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — Same as the hash map approach.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — Fixed arrays of size 26.
|
||||||
|
|
||||||
|
This variant uses fixed-size arrays instead of hash maps. Since we know the input contains only lowercase English letters, we can map each character to an index (0-25). Array comparison is slightly faster than hash map comparison in practice.
|
||||||
|
|
||||||
|
- approach_name: Sorting Each Window
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def check_inclusion(s1: str, s2: str) -> bool:
|
||||||
|
if len(s1) > len(s2):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Sort s1 once as our target
|
||||||
|
sorted_s1 = sorted(s1)
|
||||||
|
window_size = len(s1)
|
||||||
|
|
||||||
|
# Check each window by sorting and comparing
|
||||||
|
for i in range(len(s2) - window_size + 1):
|
||||||
|
window = s2[i:i + window_size]
|
||||||
|
if sorted(window) == sorted_s1:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n * k log k) — For each of the `n - k + 1` windows, we sort `k` characters.
|
||||||
|
|
||||||
|
**Space Complexity:** O(k) — Space for the sorted window.
|
||||||
|
|
||||||
|
While correct, this approach is inefficient for large inputs. Sorting each window repeatedly wastes computation. The sliding window with frequency counting avoids this by incrementally updating counts instead of recomputing from scratch.
|
||||||
231
backend/data/questions/permutations-ii.yaml
Normal file
231
backend/data/questions/permutations-ii.yaml
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
title: Permutations II
|
||||||
|
slug: permutations-ii
|
||||||
|
difficulty: medium
|
||||||
|
leetcode_id: 47
|
||||||
|
leetcode_url: https://leetcode.com/problems/permutations-ii/
|
||||||
|
categories:
|
||||||
|
- arrays
|
||||||
|
- sorting
|
||||||
|
- recursion
|
||||||
|
patterns:
|
||||||
|
- backtracking
|
||||||
|
|
||||||
|
description: |
|
||||||
|
Given a collection of numbers, `nums`, that might contain duplicates, return *all possible unique permutations **in any order***.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `1 <= nums.length <= 8`
|
||||||
|
- `-10 <= nums[i] <= 10`
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: "nums = [1,1,2]"
|
||||||
|
output: "[[1,1,2], [1,2,1], [2,1,1]]"
|
||||||
|
explanation: "The array contains a duplicate 1. We generate all unique permutations, avoiding duplicates like having two identical [1,1,2] arrangements."
|
||||||
|
- input: "nums = [1,2,3]"
|
||||||
|
output: "[[1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,1,2], [3,2,1]]"
|
||||||
|
explanation: "With all unique elements, we get the standard 3! = 6 permutations."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Imagine you have a set of letter tiles, some of which are identical. You want to arrange them in every possible order, but you don't want to count the same arrangement twice just because you swapped two identical tiles.
|
||||||
|
|
||||||
|
The core insight is that **sorting brings duplicates together**, making them easy to detect and skip. Think of it like organising your tiles alphabetically before arranging them — when you see two identical tiles next to each other, you know to use one and skip the other in the same position.
|
||||||
|
|
||||||
|
The classic permutation approach uses backtracking: at each position, try placing each available element, then recursively fill the remaining positions. The challenge with duplicates is avoiding repeated work. If we've already tried placing a `1` at position 0, we shouldn't try placing the *other* `1` at position 0 — it would produce the same permutations.
|
||||||
|
|
||||||
|
By sorting first and then skipping duplicates that would lead to repeated branches, we prune the search tree efficiently. We only use a duplicate element if we've already used the one before it in our current path.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using **Backtracking with Duplicate Skipping**:
|
||||||
|
|
||||||
|
**Step 1: Sort the input array**
|
||||||
|
|
||||||
|
- Sorting groups duplicates together: `[1, 2, 1]` becomes `[1, 1, 2]`
|
||||||
|
- This makes it easy to detect when we're about to make the same choice twice
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Set up backtracking state**
|
||||||
|
|
||||||
|
- `result`: List to store all unique permutations
|
||||||
|
- `current`: The permutation being built
|
||||||
|
- `used`: Boolean array tracking which elements are already in `current`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Define the backtracking function**
|
||||||
|
|
||||||
|
- **Base case**: If `current` has `n` elements, we found a complete permutation — add a copy to `result`
|
||||||
|
- **Recursive case**: For each index `i` in `nums`:
|
||||||
|
- Skip if `used[i]` is `True` (element already in current permutation)
|
||||||
|
- Skip if `nums[i] == nums[i-1]` AND `used[i-1]` is `False` (duplicate pruning)
|
||||||
|
- Otherwise: mark `used[i] = True`, add `nums[i]` to `current`, recurse, then backtrack
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 4: The duplicate pruning logic**
|
||||||
|
|
||||||
|
- The condition `nums[i] == nums[i-1] and not used[i-1]` means: "this element equals its predecessor, and the predecessor isn't currently in our path"
|
||||||
|
- This ensures we only use the *first* occurrence of a duplicate value at each decision point
|
||||||
|
- If the predecessor IS used, we're building a permutation that legitimately needs both duplicates
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 5: Return all collected permutations**
|
||||||
|
|
||||||
|
- After backtracking completes, `result` contains all unique permutations
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Forgetting to Sort
|
||||||
|
description: |
|
||||||
|
The duplicate-skipping logic relies on duplicates being adjacent. Without sorting, `[1, 2, 1]` won't have the duplicate `1`s next to each other, and the skip condition `nums[i] == nums[i-1]` won't work.
|
||||||
|
|
||||||
|
Always sort first: `nums.sort()` before starting backtracking.
|
||||||
|
wrong_approach: "Skip duplicates without sorting"
|
||||||
|
correct_approach: "Sort first, then skip adjacent duplicates"
|
||||||
|
|
||||||
|
- title: Wrong Duplicate Skip Condition
|
||||||
|
description: |
|
||||||
|
A common mistake is checking `nums[i] == nums[i-1]` without the `not used[i-1]` part. This would skip valid permutations that legitimately use both duplicate values.
|
||||||
|
|
||||||
|
For `[1, 1, 2]`, we *need* both `1`s in the permutation. The condition `not used[i-1]` ensures we only skip when the duplicate would create a *redundant branch*, not when we're building a permutation that requires both.
|
||||||
|
wrong_approach: "Skip whenever nums[i] == nums[i-1]"
|
||||||
|
correct_approach: "Skip only when nums[i] == nums[i-1] AND used[i-1] is False"
|
||||||
|
|
||||||
|
- title: Using a Set for Deduplication
|
||||||
|
description: |
|
||||||
|
You might think "just use a set to store results and remove duplicates". While this works, it's inefficient:
|
||||||
|
- Converting lists to tuples for set storage has overhead
|
||||||
|
- You generate duplicate permutations only to discard them
|
||||||
|
- With many duplicates, you waste significant computation
|
||||||
|
|
||||||
|
The sorting + skip approach prunes duplicates *before* generating them, which is far more efficient.
|
||||||
|
wrong_approach: "Generate all permutations, deduplicate with a set"
|
||||||
|
correct_approach: "Prune duplicate branches during backtracking"
|
||||||
|
|
||||||
|
- title: Forgetting to Copy the Current Permutation
|
||||||
|
description: |
|
||||||
|
When adding to results, use `result.append(current[:])` or `result.append(list(current))`, not `result.append(current)`.
|
||||||
|
|
||||||
|
The `current` list is mutated during backtracking. If you append the reference directly, all entries in `result` will point to the same (eventually empty) list.
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Sorting enables efficient duplicate handling**: Bringing duplicates together lets you detect and skip them with a simple adjacent comparison"
|
||||||
|
- "**The skip condition has two parts**: `nums[i] == nums[i-1]` detects duplicates, but `not used[i-1]` ensures we only skip redundant branches, not legitimate uses of both duplicates"
|
||||||
|
- "**Prune early, not late**: Avoiding duplicate work during generation is more efficient than deduplicating results afterward"
|
||||||
|
- "**Backtracking template**: This pattern (choose, explore, unchoose) with constraint checking applies to many combinatorial problems like N-Queens, Sudoku, and subset generation"
|
||||||
|
|
||||||
|
time_complexity: "O(n! * n). In the worst case (all unique elements), we generate n! permutations, each taking O(n) time to copy. With duplicates, the actual count is lower: n! / (k1! * k2! * ...) where ki is the count of each duplicate value."
|
||||||
|
space_complexity: "O(n). The recursion depth is at most n, and we use a `used` array of size n. The output space for storing permutations is O(n! * n) but is typically not counted as auxiliary space."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Backtracking with Duplicate Skipping
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def permute_unique(nums: list[int]) -> list[list[int]]:
|
||||||
|
result = []
|
||||||
|
nums.sort() # Sort to bring duplicates together
|
||||||
|
used = [False] * len(nums)
|
||||||
|
|
||||||
|
def backtrack(current: list[int]) -> None:
|
||||||
|
# Base case: found a complete permutation
|
||||||
|
if len(current) == len(nums):
|
||||||
|
result.append(current[:]) # Append a copy
|
||||||
|
return
|
||||||
|
|
||||||
|
for i in range(len(nums)):
|
||||||
|
# Skip if already used in current permutation
|
||||||
|
if used[i]:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip duplicate: same value as previous, but previous not used
|
||||||
|
# This means we'd be starting a redundant branch
|
||||||
|
if i > 0 and nums[i] == nums[i - 1] and not used[i - 1]:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Choose: add nums[i] to current permutation
|
||||||
|
used[i] = True
|
||||||
|
current.append(nums[i])
|
||||||
|
|
||||||
|
# Explore: recurse to fill remaining positions
|
||||||
|
backtrack(current)
|
||||||
|
|
||||||
|
# Unchoose: backtrack to try other options
|
||||||
|
current.pop()
|
||||||
|
used[i] = False
|
||||||
|
|
||||||
|
backtrack([])
|
||||||
|
return result
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n! * n) — We generate up to n! permutations (fewer with duplicates), each requiring O(n) to copy.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n) — Recursion stack depth is n, plus the `used` array of size n.
|
||||||
|
|
||||||
|
The key optimisation is the duplicate skip condition on line 17. By sorting first, duplicates are adjacent. When we encounter a duplicate that's the same as its unused predecessor, we skip it — the predecessor will handle all permutations starting with that value.
|
||||||
|
|
||||||
|
- approach_name: Backtracking with Counter
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
def permute_unique(nums: list[int]) -> list[list[int]]:
|
||||||
|
result = []
|
||||||
|
counter = Counter(nums) # Count occurrences of each number
|
||||||
|
|
||||||
|
def backtrack(current: list[int]) -> None:
|
||||||
|
# Base case: permutation complete
|
||||||
|
if len(current) == len(nums):
|
||||||
|
result.append(current[:])
|
||||||
|
return
|
||||||
|
|
||||||
|
# Try each unique number that still has remaining count
|
||||||
|
for num in counter:
|
||||||
|
if counter[num] > 0:
|
||||||
|
# Choose: use one instance of this number
|
||||||
|
current.append(num)
|
||||||
|
counter[num] -= 1
|
||||||
|
|
||||||
|
# Explore
|
||||||
|
backtrack(current)
|
||||||
|
|
||||||
|
# Unchoose: restore count
|
||||||
|
current.pop()
|
||||||
|
counter[num] += 1
|
||||||
|
|
||||||
|
backtrack([])
|
||||||
|
return result
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n! * n) — Same as the sorting approach.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n) — Counter uses O(k) space where k is the number of unique elements, plus O(n) recursion depth.
|
||||||
|
|
||||||
|
This alternative approach uses a Counter to track available elements. By iterating over unique keys rather than indices, we naturally avoid considering the same value twice at the same decision point. Each unique value is tried exactly once per recursive level.
|
||||||
|
|
||||||
|
- approach_name: Brute Force with Set Deduplication
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def permute_unique(nums: list[int]) -> list[list[int]]:
|
||||||
|
result_set = set()
|
||||||
|
|
||||||
|
def backtrack(current: list[int], remaining: list[int]) -> None:
|
||||||
|
if not remaining:
|
||||||
|
# Convert to tuple for set storage
|
||||||
|
result_set.add(tuple(current))
|
||||||
|
return
|
||||||
|
|
||||||
|
for i in range(len(remaining)):
|
||||||
|
# Choose element at index i
|
||||||
|
backtrack(
|
||||||
|
current + [remaining[i]],
|
||||||
|
remaining[:i] + remaining[i + 1:]
|
||||||
|
)
|
||||||
|
|
||||||
|
backtrack([], nums)
|
||||||
|
# Convert tuples back to lists
|
||||||
|
return [list(perm) for perm in result_set]
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n! * n) — Generates all n! permutations regardless of duplicates, plus set operations.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n! * n) — Stores all permutations as tuples in a set.
|
||||||
|
|
||||||
|
This naive approach generates all permutations and relies on set deduplication. While correct, it wastes computation by generating duplicate permutations that are later discarded. For input like `[1,1,1,1,1,1,1,1]` (8 identical elements), this generates 8! = 40,320 permutations but keeps only 1. The optimised approaches generate only the unique ones.
|
||||||
202
backend/data/questions/permutations.yaml
Normal file
202
backend/data/questions/permutations.yaml
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
title: Permutations
|
||||||
|
slug: permutations
|
||||||
|
difficulty: medium
|
||||||
|
leetcode_id: 46
|
||||||
|
leetcode_url: https://leetcode.com/problems/permutations/
|
||||||
|
categories:
|
||||||
|
- arrays
|
||||||
|
- recursion
|
||||||
|
patterns:
|
||||||
|
- backtracking
|
||||||
|
|
||||||
|
description: |
|
||||||
|
Given an array `nums` of distinct integers, return *all the possible permutations*. You can return the answer in **any order**.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `1 <= nums.length <= 6`
|
||||||
|
- `-10 <= nums[i] <= 10`
|
||||||
|
- All the integers of `nums` are **unique**
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: "nums = [1,2,3]"
|
||||||
|
output: "[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]"
|
||||||
|
explanation: "All 6 permutations of the three distinct integers."
|
||||||
|
- input: "nums = [0,1]"
|
||||||
|
output: "[[0,1],[1,0]]"
|
||||||
|
explanation: "Both orderings of two elements."
|
||||||
|
- input: "nums = [1]"
|
||||||
|
output: "[[1]]"
|
||||||
|
explanation: "A single element has only one permutation."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Imagine you have a set of numbered cards and want to arrange them in every possible order on a table.
|
||||||
|
|
||||||
|
For each position in the arrangement, you pick one card from the remaining unused cards, place it, then move to the next position. Once all positions are filled, you have one complete permutation. To find *all* permutations, you systematically try every possible choice at each position.
|
||||||
|
|
||||||
|
Think of it like a decision tree: at each level, you branch out by choosing one of the remaining elements. When you reach a leaf (all elements placed), you've found a valid permutation. Then you **backtrack** — undo your last choice and try a different one.
|
||||||
|
|
||||||
|
This is the essence of backtracking: build a solution incrementally, and when you complete one solution or hit a dead end, reverse your last decision to explore other possibilities.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using **Backtracking**:
|
||||||
|
|
||||||
|
**Step 1: Set up the recursive function**
|
||||||
|
|
||||||
|
- Create a helper function that takes the current partial permutation being built
|
||||||
|
- Maintain a way to track which elements have been used (either via a set, or by swapping elements in-place)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Base case**
|
||||||
|
|
||||||
|
- When the current permutation has the same length as the input array, we've placed all elements
|
||||||
|
- Add a copy of the current permutation to our results list
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Recursive case — explore all choices**
|
||||||
|
|
||||||
|
- For each element in `nums` that hasn't been used yet:
|
||||||
|
- **Choose**: Add the element to the current permutation, mark it as used
|
||||||
|
- **Explore**: Recursively call the function to fill the next position
|
||||||
|
- **Unchoose (Backtrack)**: Remove the element from the current permutation, mark it as unused
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 4: Return all collected permutations**
|
||||||
|
|
||||||
|
- After the recursion completes, the results list contains all valid permutations
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
The backtracking pattern ensures we systematically explore every possible ordering without repetition.
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Forgetting to Backtrack
|
||||||
|
description: |
|
||||||
|
After exploring a branch, you must undo your choice before trying the next option. If you forget to remove the element from the current path or unmark it as used, subsequent branches will have incorrect state.
|
||||||
|
|
||||||
|
For example, if you add `2` to the path and recurse, you must remove `2` after returning — otherwise the next iteration might try to build `[1, 2, 3, 2]` which is invalid.
|
||||||
|
wrong_approach: "Only adding elements without removing them after recursion"
|
||||||
|
correct_approach: "Add element, recurse, then remove element (backtrack)"
|
||||||
|
|
||||||
|
- title: Storing References Instead of Copies
|
||||||
|
description: |
|
||||||
|
When you find a valid permutation and add it to results, you must add a **copy** of the current path, not a reference to it.
|
||||||
|
|
||||||
|
If you do `results.append(current_path)` without copying, all entries in results will point to the same list object, which keeps changing as you backtrack. You'll end up with all identical (and likely empty) lists.
|
||||||
|
wrong_approach: "results.append(current_path)"
|
||||||
|
correct_approach: "results.append(current_path.copy()) or results.append(list(current_path))"
|
||||||
|
|
||||||
|
- title: Not Handling the Used Set Correctly
|
||||||
|
description: |
|
||||||
|
When using a set to track used elements, make sure to:
|
||||||
|
- Add the element to `used` before recursing
|
||||||
|
- Remove it from `used` after recursing
|
||||||
|
|
||||||
|
Alternatively, you can use the element's index instead of its value, or use an in-place swap approach to avoid the set entirely.
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Backtracking template**: Choose, explore, unchoose — this pattern applies to permutations, combinations, subsets, and many constraint satisfaction problems"
|
||||||
|
- "**Time complexity is inherently factorial**: Generating all permutations of `n` elements requires O(n!) work since there are n! permutations, each of length n"
|
||||||
|
- "**In-place swapping alternative**: Instead of tracking used elements, you can swap elements to the 'current position' and recurse on the remaining portion"
|
||||||
|
- "**Foundation for harder problems**: Permutations II (with duplicates) builds directly on this, adding skip logic for repeated elements"
|
||||||
|
|
||||||
|
time_complexity: "O(n * n!). There are n! permutations, and we spend O(n) time copying each one to the result."
|
||||||
|
space_complexity: "O(n). The recursion stack depth is n, and the current path stores up to n elements. The output list of n! permutations is typically not counted in auxiliary space."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Backtracking with Used Set
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def permute(nums: list[int]) -> list[list[int]]:
|
||||||
|
results = []
|
||||||
|
|
||||||
|
def backtrack(current_path: list[int], used: set[int]) -> None:
|
||||||
|
# Base case: permutation is complete
|
||||||
|
if len(current_path) == len(nums):
|
||||||
|
results.append(current_path.copy()) # Must copy!
|
||||||
|
return
|
||||||
|
|
||||||
|
# Try each unused element
|
||||||
|
for num in nums:
|
||||||
|
if num in used:
|
||||||
|
continue # Skip already-used elements
|
||||||
|
|
||||||
|
# Choose
|
||||||
|
current_path.append(num)
|
||||||
|
used.add(num)
|
||||||
|
|
||||||
|
# Explore
|
||||||
|
backtrack(current_path, used)
|
||||||
|
|
||||||
|
# Unchoose (backtrack)
|
||||||
|
current_path.pop()
|
||||||
|
used.remove(num)
|
||||||
|
|
||||||
|
backtrack([], set())
|
||||||
|
return results
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n * n!) — We generate all n! permutations, and copying each permutation takes O(n).
|
||||||
|
|
||||||
|
**Space Complexity:** O(n) — The recursion depth is n, plus the current path and used set each hold at most n elements.
|
||||||
|
|
||||||
|
This approach uses a set to track which elements have been included in the current permutation. At each step, we iterate through all elements and skip those already used.
|
||||||
|
|
||||||
|
- approach_name: Backtracking with In-Place Swaps
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def permute(nums: list[int]) -> list[list[int]]:
|
||||||
|
results = []
|
||||||
|
|
||||||
|
def backtrack(start: int) -> None:
|
||||||
|
# Base case: all positions filled
|
||||||
|
if start == len(nums):
|
||||||
|
results.append(nums.copy())
|
||||||
|
return
|
||||||
|
|
||||||
|
# Try each element from start to end in the current position
|
||||||
|
for i in range(start, len(nums)):
|
||||||
|
# Swap element at i into the start position
|
||||||
|
nums[start], nums[i] = nums[i], nums[start]
|
||||||
|
|
||||||
|
# Recurse to fill remaining positions
|
||||||
|
backtrack(start + 1)
|
||||||
|
|
||||||
|
# Swap back to restore original order
|
||||||
|
nums[start], nums[i] = nums[i], nums[start]
|
||||||
|
|
||||||
|
backtrack(0)
|
||||||
|
return results
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n * n!) — Same as the first approach.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n) — Only the recursion stack, no additional data structures for tracking used elements.
|
||||||
|
|
||||||
|
This approach avoids the `used` set by swapping elements in place. The portion `nums[0:start]` represents the current partial permutation, and we try placing each element from `nums[start:]` at position `start` by swapping.
|
||||||
|
|
||||||
|
- approach_name: Iterative (Build Permutations Step by Step)
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def permute(nums: list[int]) -> list[list[int]]:
|
||||||
|
# Start with an empty permutation
|
||||||
|
result = [[]]
|
||||||
|
|
||||||
|
for num in nums:
|
||||||
|
new_result = []
|
||||||
|
# For each existing partial permutation
|
||||||
|
for perm in result:
|
||||||
|
# Insert num at every possible position
|
||||||
|
for i in range(len(perm) + 1):
|
||||||
|
new_perm = perm[:i] + [num] + perm[i:]
|
||||||
|
new_result.append(new_perm)
|
||||||
|
result = new_result
|
||||||
|
|
||||||
|
return result
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n * n!) — Same overall, though with different constant factors.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n * n!) — We store all intermediate permutations, not just the final ones.
|
||||||
|
|
||||||
|
This iterative approach builds permutations by inserting each new number into all possible positions of existing permutations. While it avoids recursion, it uses more memory because it stores all partial results at each stage.
|
||||||
174
backend/data/questions/plus-one.yaml
Normal file
174
backend/data/questions/plus-one.yaml
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
title: Plus One
|
||||||
|
slug: plus-one
|
||||||
|
difficulty: easy
|
||||||
|
leetcode_id: 66
|
||||||
|
leetcode_url: https://leetcode.com/problems/plus-one/
|
||||||
|
categories:
|
||||||
|
- arrays
|
||||||
|
- math
|
||||||
|
patterns:
|
||||||
|
- greedy
|
||||||
|
|
||||||
|
function_signature: "def plus_one(digits: list[int]) -> list[int]:"
|
||||||
|
|
||||||
|
test_cases:
|
||||||
|
visible:
|
||||||
|
- input: { digits: [1, 2, 3] }
|
||||||
|
expected: [1, 2, 4]
|
||||||
|
- input: { digits: [4, 3, 2, 1] }
|
||||||
|
expected: [4, 3, 2, 2]
|
||||||
|
- input: { digits: [9] }
|
||||||
|
expected: [1, 0]
|
||||||
|
hidden:
|
||||||
|
- input: { digits: [0] }
|
||||||
|
expected: [1]
|
||||||
|
- input: { digits: [9, 9] }
|
||||||
|
expected: [1, 0, 0]
|
||||||
|
- input: { digits: [1, 9] }
|
||||||
|
expected: [2, 0]
|
||||||
|
- input: { digits: [9, 9, 9] }
|
||||||
|
expected: [1, 0, 0, 0]
|
||||||
|
|
||||||
|
description: |
|
||||||
|
You are given a **large integer** represented as an integer array `digits`, where each `digits[i]` is the i<sup>th</sup> digit of the integer. The digits are ordered from most significant to least significant in left-to-right order. The large integer does not contain any leading `0`'s.
|
||||||
|
|
||||||
|
Increment the large integer by one and return *the resulting array of digits*.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `1 <= digits.length <= 100`
|
||||||
|
- `0 <= digits[i] <= 9`
|
||||||
|
- `digits` does not contain any leading `0`'s
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: "digits = [1,2,3]"
|
||||||
|
output: "[1,2,4]"
|
||||||
|
explanation: "The array represents the integer 123. Incrementing by one gives 123 + 1 = 124. Thus, the result should be [1,2,4]."
|
||||||
|
- input: "digits = [4,3,2,1]"
|
||||||
|
output: "[4,3,2,2]"
|
||||||
|
explanation: "The array represents the integer 4321. Incrementing by one gives 4321 + 1 = 4322. Thus, the result should be [4,3,2,2]."
|
||||||
|
- input: "digits = [9]"
|
||||||
|
output: "[1,0]"
|
||||||
|
explanation: "The array represents the integer 9. Incrementing by one gives 9 + 1 = 10. Thus, the result should be [1,0]."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Think about how you add 1 to a number by hand. You start from the **rightmost digit** (the ones place) and add 1. If that digit is less than 9, you simply increment it and you're done. But if the digit is 9, it becomes 0 and you "carry" the 1 to the next digit on the left.
|
||||||
|
|
||||||
|
The key insight is that **carry propagation only continues while we encounter 9s**. As soon as we hit a digit that isn't 9, we can increment it, and there's no more carry to propagate. This means most numbers only require changing the last digit.
|
||||||
|
|
||||||
|
The tricky edge case is when *all* digits are 9 (like `999`). In this case, the carry propagates all the way through, and we need an extra digit at the front. The number `999 + 1 = 1000` goes from 3 digits to 4 digits.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this by **simulating the addition process** from right to left:
|
||||||
|
|
||||||
|
**Step 1: Iterate from the rightmost digit**
|
||||||
|
|
||||||
|
- Start at index `n - 1` (the last digit) and move left
|
||||||
|
- This mirrors how we do addition by hand, starting from the ones place
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Handle each digit**
|
||||||
|
|
||||||
|
- If the current digit is less than `9`, simply increment it and return the array immediately
|
||||||
|
- If the current digit is `9`, set it to `0` and continue to the next digit (the carry propagates)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Handle the all-nines case**
|
||||||
|
|
||||||
|
- If we exit the loop without returning, every digit was `9` and is now `0`
|
||||||
|
- We need to prepend a `1` to the front
|
||||||
|
- A clean way: create a new array of length `n + 1`, set the first element to `1` (the rest default to `0`)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
This approach is efficient because we stop as soon as we can (no carry to propagate) and only create a new array when absolutely necessary.
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Converting to Integer
|
||||||
|
description: |
|
||||||
|
A tempting approach is to convert the array to an integer, add 1, and convert back:
|
||||||
|
```python
|
||||||
|
num = int(''.join(map(str, digits)))
|
||||||
|
return [int(d) for d in str(num + 1)]
|
||||||
|
```
|
||||||
|
|
||||||
|
This works for small numbers but **fails for very large inputs**. With `digits.length <= 100`, the number could have 100 digits. This exceeds the range of standard 64-bit integers (about 19 digits). While Python handles arbitrary precision integers, this approach is inefficient and misses the point of the problem.
|
||||||
|
wrong_approach: "Convert to integer, add 1, convert back"
|
||||||
|
correct_approach: "Process digit by digit with carry propagation"
|
||||||
|
|
||||||
|
- title: Forgetting the Carry Propagation
|
||||||
|
description: |
|
||||||
|
Simply incrementing the last digit without checking for 9:
|
||||||
|
```python
|
||||||
|
digits[-1] += 1
|
||||||
|
return digits
|
||||||
|
```
|
||||||
|
|
||||||
|
This breaks for inputs like `[1, 9]` which should become `[2, 0]`, not `[1, 10]`. Every `9` must become `0` with a carry to the next position.
|
||||||
|
wrong_approach: "Only increment the last digit"
|
||||||
|
correct_approach: "Check if digit is 9 and propagate carry"
|
||||||
|
|
||||||
|
- title: Forgetting the All-Nines Case
|
||||||
|
description: |
|
||||||
|
If you don't handle the case where all digits are `9`, inputs like `[9, 9, 9]` will incorrectly return `[0, 0, 0]` instead of `[1, 0, 0, 0]`.
|
||||||
|
|
||||||
|
The array needs to grow by one element when the carry propagates past the most significant digit.
|
||||||
|
wrong_approach: "Not handling array expansion"
|
||||||
|
correct_approach: "Create a new array with leading 1 when all digits were 9"
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Right-to-left processing**: When simulating arithmetic operations on digit arrays, process from the least significant digit (rightmost) to handle carries naturally"
|
||||||
|
- "**Early termination**: Stop as soon as there's no carry to propagate; most cases complete after touching just one digit"
|
||||||
|
- "**Edge case awareness**: The all-nines case (`999 -> 1000`) requires special handling since the array length changes"
|
||||||
|
- "**In-place when possible**: Modify the input array directly rather than creating new arrays, except when the size must change"
|
||||||
|
|
||||||
|
time_complexity: "O(n) in the worst case, where `n` is the number of digits. We may need to visit every digit if they're all `9`s. In practice, most cases are O(1) since we return as soon as we find a non-9 digit."
|
||||||
|
space_complexity: "O(1) for most cases since we modify the array in-place. O(n) only when all digits are `9` and we need to create a new array of size `n + 1`."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Right-to-Left Carry Propagation
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def plus_one(digits: list[int]) -> list[int]:
|
||||||
|
n = len(digits)
|
||||||
|
|
||||||
|
# Process from rightmost digit to leftmost
|
||||||
|
for i in range(n - 1, -1, -1):
|
||||||
|
# If digit is less than 9, no carry needed
|
||||||
|
if digits[i] < 9:
|
||||||
|
digits[i] += 1
|
||||||
|
return digits
|
||||||
|
|
||||||
|
# Digit is 9, set to 0 and continue (carry propagates)
|
||||||
|
digits[i] = 0
|
||||||
|
|
||||||
|
# If we're here, all digits were 9 (e.g., 999 -> 1000)
|
||||||
|
# Create new array with leading 1
|
||||||
|
return [1] + digits
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — We traverse at most all n digits when all are 9s.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) for typical cases, O(n) when creating a new array for the all-nines case.
|
||||||
|
|
||||||
|
We iterate from right to left, incrementing if possible or setting to 0 and continuing. The loop naturally terminates early when we find a digit less than 9. The `[1] + digits` at the end handles the edge case where we need an extra digit.
|
||||||
|
|
||||||
|
- approach_name: Convert to Integer
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def plus_one(digits: list[int]) -> list[int]:
|
||||||
|
# Join digits into a string, convert to int
|
||||||
|
num = int(''.join(map(str, digits)))
|
||||||
|
|
||||||
|
# Add one
|
||||||
|
num += 1
|
||||||
|
|
||||||
|
# Convert back to list of digits
|
||||||
|
return [int(d) for d in str(num)]
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — String operations on n digits.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n) — Creating string representations.
|
||||||
|
|
||||||
|
This approach works in Python due to arbitrary precision integers, but it's inefficient and doesn't demonstrate understanding of the problem. It would fail in languages with fixed integer sizes for large inputs (100+ digits exceed 64-bit integers). Included to show what to avoid.
|
||||||
202
backend/data/questions/powx-n.yaml
Normal file
202
backend/data/questions/powx-n.yaml
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
title: Pow(x, n)
|
||||||
|
slug: powx-n
|
||||||
|
difficulty: medium
|
||||||
|
leetcode_id: 50
|
||||||
|
leetcode_url: https://leetcode.com/problems/powx-n/
|
||||||
|
categories:
|
||||||
|
- math
|
||||||
|
- recursion
|
||||||
|
patterns:
|
||||||
|
- binary-search
|
||||||
|
|
||||||
|
description: |
|
||||||
|
Implement `pow(x, n)`, which calculates `x` raised to the power `n` (i.e., x<sup>n</sup>).
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `-100.0 < x < 100.0`
|
||||||
|
- `-2^31 <= n <= 2^31 - 1`
|
||||||
|
- `n` is an integer
|
||||||
|
- Either `x` is not zero or `n > 0`
|
||||||
|
- `-10^4 <= x^n <= 10^4`
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: "x = 2.00000, n = 10"
|
||||||
|
output: "1024.00000"
|
||||||
|
explanation: "2^10 = 1024"
|
||||||
|
- input: "x = 2.10000, n = 3"
|
||||||
|
output: "9.26100"
|
||||||
|
explanation: "2.1^3 = 2.1 × 2.1 × 2.1 = 9.261"
|
||||||
|
- input: "x = 2.00000, n = -2"
|
||||||
|
output: "0.25000"
|
||||||
|
explanation: "2^(-2) = 1/2^2 = 1/4 = 0.25"
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Imagine you need to calculate 2<sup>10</sup>. The naive approach would multiply 2 by itself 10 times, but there's a much faster way.
|
||||||
|
|
||||||
|
Notice that 2<sup>10</sup> = 2<sup>5</sup> × 2<sup>5</sup>. We only need to calculate 2<sup>5</sup> once and square it! Similarly, 2<sup>5</sup> = 2<sup>2</sup> × 2<sup>2</sup> × 2. This pattern of **halving the exponent** at each step is the key insight.
|
||||||
|
|
||||||
|
Think of it like this: instead of taking `n` steps to reach your answer, you can "teleport" by repeatedly squaring. Each squaring operation effectively doubles the exponent you've computed so far. This transforms an O(n) problem into O(log n).
|
||||||
|
|
||||||
|
For negative exponents, remember that x<sup>-n</sup> = 1/x<sup>n</sup>. We can convert the problem to a positive exponent and take the reciprocal at the end.
|
||||||
|
|
||||||
|
This technique is called **Binary Exponentiation** (or "exponentiation by squaring") because we're essentially using the binary representation of `n` to decide when to multiply.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using **Binary Exponentiation (Iterative)**:
|
||||||
|
|
||||||
|
**Step 1: Handle negative exponents**
|
||||||
|
|
||||||
|
- If `n` is negative, convert to positive: set `n = -n` and `x = 1/x`
|
||||||
|
- This transforms x<sup>-n</sup> into (1/x)<sup>n</sup>
|
||||||
|
- Use a long integer for `n` to handle the edge case where `n = -2^31` (converting to positive would overflow a 32-bit int)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Initialise variables**
|
||||||
|
|
||||||
|
- `result`: Set to `1.0` — this accumulates our answer
|
||||||
|
- `current_product`: Set to `x` — this tracks x<sup>2^k</sup> as we iterate
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Iterate while n > 0**
|
||||||
|
|
||||||
|
- Check if the current bit of `n` is set (i.e., `n % 2 == 1` or `n & 1`)
|
||||||
|
- If yes, multiply `result` by `current_product`
|
||||||
|
- Square `current_product` to move to the next power of 2
|
||||||
|
- Halve `n` (integer division by 2 or right shift)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 4: Return the result**
|
||||||
|
|
||||||
|
- After processing all bits of `n`, return `result`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
The key insight is that any integer `n` can be expressed as a sum of powers of 2 (its binary representation). For example, 13 = 8 + 4 + 1 = 2<sup>3</sup> + 2<sup>2</sup> + 2<sup>0</sup>. So x<sup>13</sup> = x<sup>8</sup> × x<sup>4</sup> × x<sup>1</sup>. We compute each x<sup>2^k</sup> by repeated squaring and multiply them together when the corresponding bit is set.
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Linear Time Multiplication
|
||||||
|
description: |
|
||||||
|
The naive approach of multiplying `x` by itself `n` times takes O(n) time:
|
||||||
|
|
||||||
|
```python
|
||||||
|
result = 1
|
||||||
|
for _ in range(n):
|
||||||
|
result *= x
|
||||||
|
```
|
||||||
|
|
||||||
|
With constraints allowing `n` up to 2<sup>31</sup> - 1 (about 2 billion), this approach will cause a **Time Limit Exceeded** error. Binary exponentiation reduces this to O(log n), which is approximately 31 operations for the worst case.
|
||||||
|
wrong_approach: "Loop n times multiplying x"
|
||||||
|
correct_approach: "Binary exponentiation with O(log n) multiplications"
|
||||||
|
|
||||||
|
- title: Integer Overflow with Negative Exponent
|
||||||
|
description: |
|
||||||
|
When `n = -2^31` (the minimum 32-bit signed integer), converting it to positive by doing `n = -n` causes integer overflow because `2^31` cannot be represented in a signed 32-bit integer.
|
||||||
|
|
||||||
|
For example, in many languages `-(-2147483648)` still equals `-2147483648` due to overflow.
|
||||||
|
|
||||||
|
The fix is to use a 64-bit integer (long) for `n` after conversion, or handle this edge case separately.
|
||||||
|
wrong_approach: "Convert n to positive using 32-bit int"
|
||||||
|
correct_approach: "Use 64-bit integer or handle MIN_INT edge case"
|
||||||
|
|
||||||
|
- title: Forgetting to Handle n = 0
|
||||||
|
description: |
|
||||||
|
By mathematical convention, x<sup>0</sup> = 1 for any non-zero `x`. Your algorithm should return `1.0` when `n = 0`.
|
||||||
|
|
||||||
|
Initialising `result = 1.0` handles this automatically — if `n = 0`, the while loop never executes and we return `1.0`.
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Binary exponentiation pattern**: Reduce O(n) to O(log n) by repeatedly squaring and using the binary representation of the exponent"
|
||||||
|
- "**Divide and conquer**: x<sup>n</sup> = x<sup>n/2</sup> × x<sup>n/2</sup> — solve half the problem and combine"
|
||||||
|
- "**Handles negative exponents**: Transform x<sup>-n</sup> to (1/x)<sup>n</sup>"
|
||||||
|
- "**Foundation for modular exponentiation**: This same technique is used in cryptography (RSA) to compute large powers under a modulus efficiently"
|
||||||
|
|
||||||
|
time_complexity: "O(log n). We halve `n` at each iteration, so we perform at most log₂(n) multiplications."
|
||||||
|
space_complexity: "O(1). We only use a constant number of variables (`result`, `current_product`, `n`)."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Binary Exponentiation (Iterative)
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def my_pow(x: float, n: int) -> float:
|
||||||
|
# Handle negative exponent: x^(-n) = (1/x)^n
|
||||||
|
if n < 0:
|
||||||
|
x = 1 / x
|
||||||
|
n = -n
|
||||||
|
|
||||||
|
result = 1.0
|
||||||
|
current_product = x # Tracks x^(2^k)
|
||||||
|
|
||||||
|
while n > 0:
|
||||||
|
# If current bit is set, include this power in result
|
||||||
|
if n % 2 == 1:
|
||||||
|
result *= current_product
|
||||||
|
|
||||||
|
# Square to get next power: x^(2^k) -> x^(2^(k+1))
|
||||||
|
current_product *= current_product
|
||||||
|
|
||||||
|
# Move to next bit
|
||||||
|
n //= 2
|
||||||
|
|
||||||
|
return result
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(log n) — We halve `n` at each iteration.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — Only constant extra space used.
|
||||||
|
|
||||||
|
We iterate through the bits of `n`, squaring our base at each step. When a bit is set, we multiply that power into our result. This efficiently computes x<sup>n</sup> using the binary representation of `n`.
|
||||||
|
|
||||||
|
- approach_name: Binary Exponentiation (Recursive)
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def my_pow(x: float, n: int) -> float:
|
||||||
|
def helper(base: float, exp: int) -> float:
|
||||||
|
# Base case: anything to the power 0 is 1
|
||||||
|
if exp == 0:
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
# Recursively compute half the power
|
||||||
|
half = helper(base, exp // 2)
|
||||||
|
|
||||||
|
# If exp is even: x^n = (x^(n/2))^2
|
||||||
|
if exp % 2 == 0:
|
||||||
|
return half * half
|
||||||
|
# If exp is odd: x^n = x * (x^(n/2))^2
|
||||||
|
else:
|
||||||
|
return base * half * half
|
||||||
|
|
||||||
|
# Handle negative exponent
|
||||||
|
if n < 0:
|
||||||
|
return helper(1 / x, -n)
|
||||||
|
return helper(x, n)
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(log n) — Recursion depth is log₂(n).
|
||||||
|
|
||||||
|
**Space Complexity:** O(log n) — Due to recursion stack.
|
||||||
|
|
||||||
|
This recursive approach expresses the same idea: x<sup>n</sup> = x<sup>n/2</sup> × x<sup>n/2</sup> (times x if n is odd). The iterative version is preferred for its O(1) space complexity.
|
||||||
|
|
||||||
|
- approach_name: Brute Force
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def my_pow(x: float, n: int) -> float:
|
||||||
|
# Handle negative exponent
|
||||||
|
if n < 0:
|
||||||
|
x = 1 / x
|
||||||
|
n = -n
|
||||||
|
|
||||||
|
result = 1.0
|
||||||
|
# Multiply x by itself n times
|
||||||
|
for _ in range(n):
|
||||||
|
result *= x
|
||||||
|
|
||||||
|
return result
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — Linear in the exponent value.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — Only constant extra space.
|
||||||
|
|
||||||
|
This naive approach multiplies `x` by itself `n` times. While correct, it's far too slow when `n` can be up to 2^31. Included here to illustrate why binary exponentiation is necessary.
|
||||||
169
backend/data/questions/product-of-array-except-self.yaml
Normal file
169
backend/data/questions/product-of-array-except-self.yaml
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
title: Product of Array Except Self
|
||||||
|
slug: product-of-array-except-self
|
||||||
|
difficulty: medium
|
||||||
|
leetcode_id: 238
|
||||||
|
leetcode_url: https://leetcode.com/problems/product-of-array-except-self/
|
||||||
|
categories:
|
||||||
|
- arrays
|
||||||
|
patterns:
|
||||||
|
- prefix-sum
|
||||||
|
|
||||||
|
description: |
|
||||||
|
Given an integer array `nums`, return an array `answer` such that `answer[i]` is equal to the product of all the elements of `nums` except `nums[i]`.
|
||||||
|
|
||||||
|
The product of any prefix or suffix of `nums` is **guaranteed** to fit in a **32-bit** integer.
|
||||||
|
|
||||||
|
You must write an algorithm that runs in **O(n)** time and **without using the division operation**.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `2 <= nums.length <= 10^5`
|
||||||
|
- `-30 <= nums[i] <= 30`
|
||||||
|
- The product of any prefix or suffix of `nums` is guaranteed to fit in a 32-bit integer
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: "nums = [1,2,3,4]"
|
||||||
|
output: "[24,12,8,6]"
|
||||||
|
explanation: "answer[0] = 2×3×4 = 24, answer[1] = 1×3×4 = 12, answer[2] = 1×2×4 = 8, answer[3] = 1×2×3 = 6"
|
||||||
|
- input: "nums = [-1,1,0,-3,3]"
|
||||||
|
output: "[0,0,9,0,0]"
|
||||||
|
explanation: "Any position except index 2 includes the zero, making the product 0. Position 2 excludes the zero: (-1)×1×(-3)×3 = 9"
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
For each position `i`, we need the product of everything **except** `nums[i]`. If division were allowed, we could compute the total product and divide by `nums[i]`. But division is forbidden (and would fail with zeros anyway).
|
||||||
|
|
||||||
|
Think of it differently: the product of "everything except position i" equals:
|
||||||
|
- (product of everything **left** of i) × (product of everything **right** of i)
|
||||||
|
|
||||||
|
This is the **prefix-suffix** insight! For each position:
|
||||||
|
- **Prefix product**: multiply all elements from the start up to (but not including) position i
|
||||||
|
- **Suffix product**: multiply all elements from position i+1 to the end
|
||||||
|
|
||||||
|
If we can compute both efficiently, we just multiply them together.
|
||||||
|
|
||||||
|
The clever optimisation: we can use the output array itself to store prefix products in a left-to-right pass, then multiply in suffix products with a right-to-left pass using a single running variable.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using **Two-Pass Prefix/Suffix Products**:
|
||||||
|
|
||||||
|
**Step 1: Initialise the answer array**
|
||||||
|
|
||||||
|
- Create `answer` of length n, initialised to 1
|
||||||
|
- This array will hold our final results
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Left pass — compute prefix products**
|
||||||
|
|
||||||
|
- Track `left_product = 1` (product of elements to the left)
|
||||||
|
- For each `i` from 0 to n-1:
|
||||||
|
- Set `answer[i] = left_product` (product of all elements before i)
|
||||||
|
- Update `left_product *= nums[i]` (include current element for next position)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Right pass — multiply by suffix products**
|
||||||
|
|
||||||
|
- Track `right_product = 1` (product of elements to the right)
|
||||||
|
- For each `i` from n-1 down to 0:
|
||||||
|
- Multiply `answer[i] *= right_product` (now contains prefix × suffix)
|
||||||
|
- Update `right_product *= nums[i]` (include current element for next position)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 4: Return the answer**
|
||||||
|
|
||||||
|
- Each `answer[i]` now contains the product of all elements except `nums[i]`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Example walkthrough with `[1,2,3,4]`:
|
||||||
|
- After left pass: `[1, 1, 2, 6]` (prefix products)
|
||||||
|
- After right pass: `[24, 12, 8, 6]` (prefix × suffix)
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Using Division
|
||||||
|
description: |
|
||||||
|
The problem explicitly forbids the division operator. Even if allowed, division fails when the array contains zeros (division by zero).
|
||||||
|
|
||||||
|
The prefix-suffix approach works without division and handles zeros naturally.
|
||||||
|
wrong_approach: "total_product // nums[i]"
|
||||||
|
correct_approach: "Prefix product × suffix product"
|
||||||
|
|
||||||
|
- title: Using Extra Space for Prefix and Suffix Arrays
|
||||||
|
description: |
|
||||||
|
A straightforward approach creates separate `prefix[]` and `suffix[]` arrays, using O(n) extra space.
|
||||||
|
|
||||||
|
The optimal solution reuses the output array for prefix products and uses a single variable for the running suffix product — O(1) extra space.
|
||||||
|
wrong_approach: "prefix = []; suffix = [] — O(n) extra space"
|
||||||
|
correct_approach: "Use output array for prefix, single variable for suffix"
|
||||||
|
|
||||||
|
- title: Off-by-One in Product Accumulation
|
||||||
|
description: |
|
||||||
|
The key insight: update `answer[i]` **before** multiplying `nums[i]` into the running product. This ensures `nums[i]` itself is excluded from the product at position i.
|
||||||
|
|
||||||
|
If you multiply first, you'll include `nums[i]` in its own product.
|
||||||
|
wrong_approach: "left_product *= nums[i]; answer[i] = left_product"
|
||||||
|
correct_approach: "answer[i] = left_product; left_product *= nums[i]"
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Prefix/suffix products**: A powerful pattern for 'exclude current element' problems"
|
||||||
|
- "**Two-pass technique**: Build partial results in one direction, complete them in the other"
|
||||||
|
- "**Space optimisation**: The output array can store intermediate results, achieving O(1) extra space"
|
||||||
|
- "**No division needed**: This approach naturally handles zeros without special cases"
|
||||||
|
|
||||||
|
time_complexity: "O(n). Two passes through the array, each doing O(1) work per element."
|
||||||
|
space_complexity: "O(1). Only two variables (`left_product` and `right_product`) beyond the output array. The output array doesn't count as extra space per the problem statement."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Two-Pass with O(1) Space
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def product_except_self(nums: list[int]) -> list[int]:
|
||||||
|
n = len(nums)
|
||||||
|
answer = [1] * n
|
||||||
|
|
||||||
|
# Left pass: answer[i] = product of all elements to the LEFT of i
|
||||||
|
left_product = 1
|
||||||
|
for i in range(n):
|
||||||
|
answer[i] = left_product
|
||||||
|
left_product *= nums[i] # Include nums[i] for positions after i
|
||||||
|
|
||||||
|
# Right pass: multiply by product of all elements to the RIGHT of i
|
||||||
|
right_product = 1
|
||||||
|
for i in range(n - 1, -1, -1):
|
||||||
|
answer[i] *= right_product
|
||||||
|
right_product *= nums[i] # Include nums[i] for positions before i
|
||||||
|
|
||||||
|
return answer
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — Two linear passes.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — Only two extra variables (output array doesn't count).
|
||||||
|
|
||||||
|
First pass stores prefix products in the answer array. Second pass multiplies each position by the suffix product. The result is the product of all elements except the current one.
|
||||||
|
|
||||||
|
- approach_name: Separate Prefix/Suffix Arrays
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def product_except_self(nums: list[int]) -> list[int]:
|
||||||
|
n = len(nums)
|
||||||
|
|
||||||
|
# Build prefix products: prefix[i] = product of nums[0..i-1]
|
||||||
|
prefix = [1] * n
|
||||||
|
for i in range(1, n):
|
||||||
|
prefix[i] = prefix[i - 1] * nums[i - 1]
|
||||||
|
|
||||||
|
# Build suffix products: suffix[i] = product of nums[i+1..n-1]
|
||||||
|
suffix = [1] * n
|
||||||
|
for i in range(n - 2, -1, -1):
|
||||||
|
suffix[i] = suffix[i + 1] * nums[i + 1]
|
||||||
|
|
||||||
|
# Combine: answer[i] = prefix[i] * suffix[i]
|
||||||
|
return [prefix[i] * suffix[i] for i in range(n)]
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — Three linear passes.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n) — Two extra arrays for prefix and suffix products.
|
||||||
|
|
||||||
|
This version is easier to understand. We compute prefix and suffix products separately, then combine them. The optimal version merges these steps to save space.
|
||||||
214
backend/data/questions/reconstruct-itinerary.yaml
Normal file
214
backend/data/questions/reconstruct-itinerary.yaml
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
title: Reconstruct Itinerary
|
||||||
|
slug: reconstruct-itinerary
|
||||||
|
difficulty: hard
|
||||||
|
leetcode_id: 332
|
||||||
|
leetcode_url: https://leetcode.com/problems/reconstruct-itinerary/
|
||||||
|
categories:
|
||||||
|
- graphs
|
||||||
|
patterns:
|
||||||
|
- dfs
|
||||||
|
|
||||||
|
description: |
|
||||||
|
You are given a list of airline `tickets` where `tickets[i] = [from_i, to_i]` represent the departure and the arrival airports of one flight. Reconstruct the itinerary in order and return it.
|
||||||
|
|
||||||
|
All of the tickets belong to a man who departs from `"JFK"`, thus, the itinerary must begin with `"JFK"`. If there are multiple valid itineraries, you should return the itinerary that has the smallest lexical order when read as a single string.
|
||||||
|
|
||||||
|
- For example, the itinerary `["JFK", "LGA"]` has a smaller lexical order than `["JFK", "LGB"]`.
|
||||||
|
|
||||||
|
You may assume all tickets form at least one valid itinerary. You must use all the tickets once and only once.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `1 <= tickets.length <= 300`
|
||||||
|
- `tickets[i].length == 2`
|
||||||
|
- `from_i.length == 3`
|
||||||
|
- `to_i.length == 3`
|
||||||
|
- `from_i` and `to_i` consist of uppercase English letters
|
||||||
|
- `from_i != to_i`
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: 'tickets = [["MUC","LHR"],["JFK","MUC"],["SFO","SJC"],["LHR","SFO"]]'
|
||||||
|
output: '["JFK","MUC","LHR","SFO","SJC"]'
|
||||||
|
explanation: "Starting from JFK, we fly to MUC, then LHR, then SFO, and finally SJC. This uses all 4 tickets exactly once."
|
||||||
|
- input: 'tickets = [["JFK","SFO"],["JFK","ATL"],["SFO","ATL"],["ATL","JFK"],["ATL","SFO"]]'
|
||||||
|
output: '["JFK","ATL","JFK","SFO","ATL","SFO"]'
|
||||||
|
explanation: "Another valid reconstruction is [\"JFK\",\"SFO\",\"ATL\",\"JFK\",\"ATL\",\"SFO\"] but it is larger in lexical order. We choose ATL first from JFK because 'ATL' < 'SFO' lexicographically."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Think of this problem as finding a path through a directed graph where each ticket is an edge. The challenge is that you must use **every edge exactly once** — this is known as an **Eulerian path**.
|
||||||
|
|
||||||
|
Imagine you're a traveler at JFK airport with a stack of plane tickets. You need to use every single ticket exactly once and end up with a valid journey. The tricky part: if there are multiple choices at any airport, you must pick the lexicographically smallest destination to get the "smallest" itinerary.
|
||||||
|
|
||||||
|
The key insight is that we can use **Hierholzer's algorithm** — a classic algorithm for finding Eulerian paths. The idea is:
|
||||||
|
|
||||||
|
1. Always greedily visit the smallest lexicographic neighbor first
|
||||||
|
2. When you reach a "dead end" (an airport with no more outgoing tickets), that airport must be the *last* stop in the final path
|
||||||
|
3. Backtrack and add airports to the result in **reverse order**
|
||||||
|
|
||||||
|
Why does this work? When we hit a dead end, we know that airport has no more outgoing flights — so it must come at the end of the itinerary. By building the result in reverse as we backtrack, we naturally construct the correct path.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using **DFS with Hierholzer's Algorithm**:
|
||||||
|
|
||||||
|
**Step 1: Build an adjacency list**
|
||||||
|
|
||||||
|
- Create a graph where each airport maps to a sorted list of destinations
|
||||||
|
- Use a data structure that allows efficient removal of the smallest element (like a heap or sorted list)
|
||||||
|
- Sorting ensures we always pick the lexicographically smallest destination first
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Perform DFS from JFK**
|
||||||
|
|
||||||
|
- Start at "JFK"
|
||||||
|
- While there are outgoing flights from the current airport:
|
||||||
|
- Pop the smallest destination (this "uses" the ticket)
|
||||||
|
- Recursively visit that destination
|
||||||
|
- When no more flights remain from an airport, add it to the result
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Reverse the result**
|
||||||
|
|
||||||
|
- Since we add airports when we backtrack (when stuck), the result is built in reverse order
|
||||||
|
- Reverse it at the end to get the correct itinerary
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
This greedy DFS approach works because the problem guarantees a valid Eulerian path exists. By always choosing the lexicographically smallest option and building the result from dead ends backward, we ensure the smallest valid itinerary.
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Using Tickets Multiple Times
|
||||||
|
description: |
|
||||||
|
A naive DFS might visit the same edge (ticket) more than once if you don't track which tickets have been used.
|
||||||
|
|
||||||
|
The key is to **remove** or mark tickets as used. Using a data structure like a list with `pop()` naturally handles this — once a destination is popped, that ticket is consumed.
|
||||||
|
wrong_approach: "Visiting neighbors without removing them"
|
||||||
|
correct_approach: "Pop destinations from the adjacency list to consume tickets"
|
||||||
|
|
||||||
|
- title: Not Handling Backtracking Correctly
|
||||||
|
description: |
|
||||||
|
If you greedily go to the smallest destination and hit a dead end with unused tickets elsewhere, you might get stuck.
|
||||||
|
|
||||||
|
Example: With tickets `[["JFK","KUL"],["JFK","NRT"],["NRT","JFK"]]`, greedily going JFK -> KUL first leads to a dead end, leaving tickets unused.
|
||||||
|
|
||||||
|
Hierholzer's algorithm handles this: when stuck at KUL, we add it to the result and backtrack. The algorithm naturally explores other branches and reconstructs the correct path.
|
||||||
|
wrong_approach: "Simple greedy without proper backtracking"
|
||||||
|
correct_approach: "Hierholzer's algorithm with post-order insertion"
|
||||||
|
|
||||||
|
- title: Forgetting Lexicographic Order
|
||||||
|
description: |
|
||||||
|
The problem requires the smallest lexical order. If you don't sort destinations before exploring, you might find *a* valid path but not *the* lexicographically smallest one.
|
||||||
|
|
||||||
|
For example, with `["JFK","ATL"]` and `["JFK","SFO"]` available, you must try ATL first because `"ATL" < "SFO"`.
|
||||||
|
wrong_approach: "Iterating destinations in arbitrary order"
|
||||||
|
correct_approach: "Sort destinations and always pick the smallest first"
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Eulerian path pattern**: When you need to traverse every edge exactly once, think Hierholzer's algorithm"
|
||||||
|
- "**Post-order construction**: Build the result during backtracking, then reverse — this naturally handles complex graph structures"
|
||||||
|
- "**Lexicographic ordering**: Use sorted data structures (heaps or sorted lists) to greedily pick the smallest option at each step"
|
||||||
|
- "**Graph as adjacency list**: Modelling tickets as directed edges enables graph traversal algorithms"
|
||||||
|
|
||||||
|
time_complexity: "O(E log E). We process each edge once, but sorting destinations takes O(E log E) where E is the number of tickets."
|
||||||
|
space_complexity: "O(E). We store all edges in the adjacency list, plus O(E) for the recursion stack in the worst case."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: DFS with Hierholzer's Algorithm
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
def find_itinerary(tickets: list[list[str]]) -> list[str]:
|
||||||
|
# Build adjacency list with sorted destinations (reversed for efficient popping)
|
||||||
|
graph = defaultdict(list)
|
||||||
|
for src, dst in sorted(tickets, reverse=True):
|
||||||
|
graph[src].append(dst)
|
||||||
|
|
||||||
|
result = []
|
||||||
|
|
||||||
|
def dfs(airport: str) -> None:
|
||||||
|
# Visit all destinations from this airport in lexicographic order
|
||||||
|
while graph[airport]:
|
||||||
|
# Pop smallest destination (from end since we sorted in reverse)
|
||||||
|
next_airport = graph[airport].pop()
|
||||||
|
dfs(next_airport)
|
||||||
|
# Add to result when no more outgoing flights (dead end)
|
||||||
|
result.append(airport)
|
||||||
|
|
||||||
|
dfs("JFK")
|
||||||
|
|
||||||
|
# Result is built in reverse order, so reverse it
|
||||||
|
return result[::-1]
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(E log E) — Sorting tickets dominates. The DFS visits each edge once.
|
||||||
|
|
||||||
|
**Space Complexity:** O(E) — Adjacency list stores all edges; recursion stack can be O(E) deep.
|
||||||
|
|
||||||
|
We sort tickets in reverse order so that popping from the list (O(1)) gives us the smallest destination. The DFS explores all paths, adding airports to the result when backtracking from dead ends. Reversing at the end gives the correct itinerary.
|
||||||
|
|
||||||
|
- approach_name: DFS with Heap
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
from collections import defaultdict
|
||||||
|
import heapq
|
||||||
|
|
||||||
|
def find_itinerary(tickets: list[list[str]]) -> list[str]:
|
||||||
|
# Build adjacency list using min-heaps for lexicographic order
|
||||||
|
graph = defaultdict(list)
|
||||||
|
for src, dst in tickets:
|
||||||
|
heapq.heappush(graph[src], dst)
|
||||||
|
|
||||||
|
result = []
|
||||||
|
|
||||||
|
def dfs(airport: str) -> None:
|
||||||
|
# Visit all destinations in lexicographic order
|
||||||
|
while graph[airport]:
|
||||||
|
# Pop smallest destination from heap
|
||||||
|
next_airport = heapq.heappop(graph[airport])
|
||||||
|
dfs(next_airport)
|
||||||
|
# Add to result when stuck
|
||||||
|
result.append(airport)
|
||||||
|
|
||||||
|
dfs("JFK")
|
||||||
|
|
||||||
|
return result[::-1]
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(E log E) — Each heap push/pop is O(log E), done E times.
|
||||||
|
|
||||||
|
**Space Complexity:** O(E) — Same as above.
|
||||||
|
|
||||||
|
This variation uses heaps instead of pre-sorted lists. The logic is identical to Hierholzer's algorithm, but `heappop()` directly gives the smallest destination. Both approaches have the same complexity; the sorted list version has slightly better constants due to cache locality.
|
||||||
|
|
||||||
|
- approach_name: Iterative with Stack
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
def find_itinerary(tickets: list[list[str]]) -> list[str]:
|
||||||
|
# Build adjacency list with sorted destinations
|
||||||
|
graph = defaultdict(list)
|
||||||
|
for src, dst in sorted(tickets, reverse=True):
|
||||||
|
graph[src].append(dst)
|
||||||
|
|
||||||
|
stack = ["JFK"]
|
||||||
|
result = []
|
||||||
|
|
||||||
|
while stack:
|
||||||
|
# Peek at current airport
|
||||||
|
airport = stack[-1]
|
||||||
|
|
||||||
|
if graph[airport]:
|
||||||
|
# More destinations available: continue exploring
|
||||||
|
stack.append(graph[airport].pop())
|
||||||
|
else:
|
||||||
|
# Dead end: add to result and backtrack
|
||||||
|
result.append(stack.pop())
|
||||||
|
|
||||||
|
return result[::-1]
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(E log E) — Same as the recursive version.
|
||||||
|
|
||||||
|
**Space Complexity:** O(E) — Explicit stack replaces recursion.
|
||||||
|
|
||||||
|
This iterative version avoids recursion, which can be useful for very deep graphs to prevent stack overflow. The logic mirrors the recursive DFS: explore while destinations exist, add to result when stuck, and reverse at the end.
|
||||||
196
backend/data/questions/redundant-connection.yaml
Normal file
196
backend/data/questions/redundant-connection.yaml
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
title: Redundant Connection
|
||||||
|
slug: redundant-connection
|
||||||
|
difficulty: medium
|
||||||
|
leetcode_id: 684
|
||||||
|
leetcode_url: https://leetcode.com/problems/redundant-connection/
|
||||||
|
categories:
|
||||||
|
- graphs
|
||||||
|
patterns:
|
||||||
|
- union-find
|
||||||
|
- dfs
|
||||||
|
|
||||||
|
description: |
|
||||||
|
In this problem, a tree is an **undirected graph** that is connected and has no cycles.
|
||||||
|
|
||||||
|
You are given a graph that started as a tree with `n` nodes labeled from `1` to `n`, with one additional edge added. The added edge has two **different** vertices chosen from `1` to `n`, and was not an edge that already existed. The graph is represented as an array `edges` of length `n` where `edges[i] = [a_i, b_i]` indicates that there is an edge between nodes `a_i` and `b_i` in the graph.
|
||||||
|
|
||||||
|
Return *an edge that can be removed so that the resulting graph is a tree of* `n` *nodes*. If there are multiple answers, return the answer that occurs last in the input.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `n == edges.length`
|
||||||
|
- `3 <= n <= 1000`
|
||||||
|
- `edges[i].length == 2`
|
||||||
|
- `1 <= a_i < b_i <= edges.length`
|
||||||
|
- `a_i != b_i`
|
||||||
|
- There are no repeated edges
|
||||||
|
- The given graph is connected
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: "edges = [[1,2],[1,3],[2,3]]"
|
||||||
|
output: "[2,3]"
|
||||||
|
explanation: "Adding edge [2,3] creates a cycle 1-2-3-1. Removing it restores the tree structure."
|
||||||
|
- input: "edges = [[1,2],[2,3],[3,4],[1,4],[1,5]]"
|
||||||
|
output: "[1,4]"
|
||||||
|
explanation: "Adding edge [1,4] creates a cycle 1-2-3-4-1. Removing it (the last edge that could be removed) restores the tree."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Imagine building a forest from scratch by adding edges one by one. Each time you add an edge, you're either **connecting two separate trees** (valid) or **connecting two nodes that are already in the same tree** (creates a cycle).
|
||||||
|
|
||||||
|
Think of it like connecting islands with bridges. If two islands are already connected (possibly through other islands), building another bridge between them creates a loop — that's the redundant connection.
|
||||||
|
|
||||||
|
The key insight is: **the first edge that connects two already-connected nodes is the edge that creates the cycle**. Since we want the last such edge in the input (if multiple exist), we process edges in order and return the last one that would create a cycle.
|
||||||
|
|
||||||
|
This is exactly what **Union-Find** (Disjoint Set Union) excels at: efficiently tracking which nodes belong to the same connected component and detecting when an edge would connect nodes already in the same component.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using **Union-Find (Disjoint Set Union)**:
|
||||||
|
|
||||||
|
**Step 1: Initialise the Union-Find structure**
|
||||||
|
|
||||||
|
- `parent`: Array where `parent[i]` initially equals `i` (each node is its own parent)
|
||||||
|
- `rank`: Array to track tree depth for union by rank optimisation
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Define helper functions**
|
||||||
|
|
||||||
|
- `find(x)`: Returns the root of `x`'s component, with path compression
|
||||||
|
- `union(x, y)`: Merges components of `x` and `y`, returns `False` if already connected
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Process each edge in order**
|
||||||
|
|
||||||
|
- For each edge `[a, b]`, attempt to union nodes `a` and `b`
|
||||||
|
- If `find(a) == find(b)`, they're already in the same component — this edge is redundant
|
||||||
|
- Return the first (and in our iteration, last-checked) edge that causes a cycle
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 4: Return the redundant edge**
|
||||||
|
|
||||||
|
- The problem guarantees exactly one redundant edge exists
|
||||||
|
- Since we process edges in order, we naturally find the last one that creates a cycle
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
The Union-Find approach is ideal here because it efficiently handles dynamic connectivity queries. Path compression and union by rank ensure near-constant time operations.
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Forgetting Path Compression
|
||||||
|
description: |
|
||||||
|
Without path compression, `find()` can degrade to O(n) per call, making the overall solution O(n^2).
|
||||||
|
|
||||||
|
Path compression flattens the tree structure by making each node point directly to the root during `find()` operations. This keeps the tree shallow and ensures near-constant time lookups.
|
||||||
|
wrong_approach: "Simple find without path compression"
|
||||||
|
correct_approach: "find() with path compression: parent[x] = find(parent[x])"
|
||||||
|
|
||||||
|
- title: Using 0-indexed Arrays with 1-indexed Nodes
|
||||||
|
description: |
|
||||||
|
The problem uses nodes labeled `1` to `n`, but many implementations use 0-indexed arrays.
|
||||||
|
|
||||||
|
Either allocate arrays of size `n + 1` (indices 0 to n, ignoring index 0), or subtract 1 from each node value. Mixing conventions leads to off-by-one errors.
|
||||||
|
wrong_approach: "Array of size n with 1-indexed node access"
|
||||||
|
correct_approach: "Array of size n+1 to accommodate 1-indexed nodes"
|
||||||
|
|
||||||
|
- title: Using DFS/BFS for Each Edge
|
||||||
|
description: |
|
||||||
|
A naive approach runs DFS/BFS before each edge to check if the two nodes are already connected. This works but is O(n^2) in the worst case.
|
||||||
|
|
||||||
|
Union-Find provides amortised O(α(n)) per operation (nearly constant), making the total time complexity O(n × α(n)) ≈ O(n).
|
||||||
|
wrong_approach: "DFS/BFS connectivity check before each edge"
|
||||||
|
correct_approach: "Union-Find with path compression and union by rank"
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Union-Find pattern**: The go-to data structure for dynamic connectivity problems — tracking which elements belong to the same group"
|
||||||
|
- "**Cycle detection in graphs**: An edge creates a cycle if and only if it connects two nodes already in the same connected component"
|
||||||
|
- "**Path compression + union by rank**: These two optimisations together give nearly O(1) amortised time per operation"
|
||||||
|
- "**Foundation for harder problems**: This pattern extends to problems like accounts merge, number of provinces, and minimum spanning trees (Kruskal's algorithm)"
|
||||||
|
|
||||||
|
time_complexity: "O(n × α(n)), where α is the inverse Ackermann function. With path compression and union by rank, each union/find operation is nearly O(1), and we perform n operations."
|
||||||
|
space_complexity: "O(n). We store parent and rank arrays of size n+1 to represent the Union-Find structure."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Union-Find
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def find_redundant_connection(edges: list[list[int]]) -> list[int]:
|
||||||
|
n = len(edges)
|
||||||
|
# Initialise parent array: each node is its own parent
|
||||||
|
parent = list(range(n + 1))
|
||||||
|
# Rank for union by rank optimisation
|
||||||
|
rank = [0] * (n + 1)
|
||||||
|
|
||||||
|
def find(x: int) -> int:
|
||||||
|
# Path compression: make each node point to root
|
||||||
|
if parent[x] != x:
|
||||||
|
parent[x] = find(parent[x])
|
||||||
|
return parent[x]
|
||||||
|
|
||||||
|
def union(x: int, y: int) -> bool:
|
||||||
|
# Find roots of both nodes
|
||||||
|
root_x, root_y = find(x), find(y)
|
||||||
|
|
||||||
|
# Already in same component - this edge creates a cycle
|
||||||
|
if root_x == root_y:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Union by rank: attach smaller tree under larger tree
|
||||||
|
if rank[root_x] < rank[root_y]:
|
||||||
|
parent[root_x] = root_y
|
||||||
|
elif rank[root_x] > rank[root_y]:
|
||||||
|
parent[root_y] = root_x
|
||||||
|
else:
|
||||||
|
parent[root_y] = root_x
|
||||||
|
rank[root_x] += 1
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Process each edge - first one that fails union is redundant
|
||||||
|
for a, b in edges:
|
||||||
|
if not union(a, b):
|
||||||
|
return [a, b]
|
||||||
|
|
||||||
|
return [] # Problem guarantees a redundant edge exists
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n × α(n)) — Each union/find is nearly O(1) with path compression and union by rank.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n) — Parent and rank arrays.
|
||||||
|
|
||||||
|
We process edges in order. For each edge, we try to union the two nodes. If they're already in the same component (same root), this edge would create a cycle — it's the redundant connection. Since we process edges in input order, we naturally return the last such edge.
|
||||||
|
|
||||||
|
- approach_name: DFS Cycle Detection
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
def find_redundant_connection(edges: list[list[int]]) -> list[int]:
|
||||||
|
graph = defaultdict(set)
|
||||||
|
|
||||||
|
def has_path(source: int, target: int, visited: set) -> bool:
|
||||||
|
# DFS to check if path exists between source and target
|
||||||
|
if source == target:
|
||||||
|
return True
|
||||||
|
visited.add(source)
|
||||||
|
for neighbour in graph[source]:
|
||||||
|
if neighbour not in visited:
|
||||||
|
if has_path(neighbour, target, visited):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
for a, b in edges:
|
||||||
|
# Before adding edge, check if nodes are already connected
|
||||||
|
if has_path(a, b, set()):
|
||||||
|
return [a, b]
|
||||||
|
# Add edge to graph
|
||||||
|
graph[a].add(b)
|
||||||
|
graph[b].add(a)
|
||||||
|
|
||||||
|
return []
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n^2) — For each of n edges, we potentially traverse up to n nodes.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n) — Graph adjacency list and recursion stack.
|
||||||
|
|
||||||
|
Before adding each edge, we use DFS to check if the two nodes are already connected. If they are, adding this edge would create a cycle. While correct, this approach is slower than Union-Find for large inputs.
|
||||||
248
backend/data/questions/regular-expression-matching.yaml
Normal file
248
backend/data/questions/regular-expression-matching.yaml
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
title: Regular Expression Matching
|
||||||
|
slug: regular-expression-matching
|
||||||
|
difficulty: hard
|
||||||
|
leetcode_id: 10
|
||||||
|
leetcode_url: https://leetcode.com/problems/regular-expression-matching/
|
||||||
|
categories:
|
||||||
|
- strings
|
||||||
|
- dynamic-programming
|
||||||
|
- recursion
|
||||||
|
patterns:
|
||||||
|
- dynamic-programming
|
||||||
|
|
||||||
|
description: |
|
||||||
|
Given an input string `s` and a pattern `p`, implement regular expression matching with support for `'.'` and `'*'` where:
|
||||||
|
|
||||||
|
- `'.'` Matches any single character.
|
||||||
|
- `'*'` Matches zero or more of the preceding element.
|
||||||
|
|
||||||
|
The matching should cover the **entire** input string (not partial).
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `1 <= s.length <= 20`
|
||||||
|
- `1 <= p.length <= 20`
|
||||||
|
- `s` contains only lowercase English letters.
|
||||||
|
- `p` contains only lowercase English letters, `'.'`, and `'*'`.
|
||||||
|
- It is guaranteed for each appearance of the character `'*'`, there will be a previous valid character to match.
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: 's = "aa", p = "a"'
|
||||||
|
output: "false"
|
||||||
|
explanation: '"a" does not match the entire string "aa".'
|
||||||
|
- input: 's = "aa", p = "a*"'
|
||||||
|
output: "true"
|
||||||
|
explanation: '"*" means zero or more of the preceding element, "a". Therefore, by repeating "a" once, it becomes "aa".'
|
||||||
|
- input: 's = "ab", p = ".*"'
|
||||||
|
output: "true"
|
||||||
|
explanation: '".*" means "zero or more (*) of any character (.)".'
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Think of this problem as a **decision tree** where at each step you must decide how to match the current characters.
|
||||||
|
|
||||||
|
The key insight is that the `'*'` wildcard creates **branching possibilities**: when you see a pattern like `a*`, you can either:
|
||||||
|
1. **Use zero occurrences** of `a` (skip `a*` entirely and move on in the pattern)
|
||||||
|
2. **Use one or more occurrences** of `a` (if the current string character matches, consume it and keep the `a*` available for more matches)
|
||||||
|
|
||||||
|
This branching nature makes the problem a natural fit for **recursion** with **memoisation** (or bottom-up dynamic programming). Without memoisation, you'd repeatedly solve the same subproblems, leading to exponential time complexity.
|
||||||
|
|
||||||
|
The `'.'` wildcard is simpler: it just matches any single character, so treat it as a "universal match" when comparing characters.
|
||||||
|
|
||||||
|
The mental model is: "At each position, what are my options, and does *any* combination of choices lead to a full match?"
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using **Dynamic Programming** with a 2D table:
|
||||||
|
|
||||||
|
**Step 1: Define the DP state**
|
||||||
|
|
||||||
|
- `dp[i][j]`: Whether `s[0:i]` matches `p[0:j]`
|
||||||
|
- Our answer will be `dp[len(s)][len(p)]`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Initialise the base cases**
|
||||||
|
|
||||||
|
- `dp[0][0] = True`: Empty string matches empty pattern
|
||||||
|
- `dp[0][j]`: Empty string can match patterns like `a*b*c*` where each `x*` uses zero occurrences
|
||||||
|
- `dp[i][0] = False` for `i > 0`: Non-empty string cannot match empty pattern
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Fill the DP table**
|
||||||
|
|
||||||
|
For each cell `dp[i][j]`, we consider the current pattern character `p[j-1]`:
|
||||||
|
|
||||||
|
- **Case 1: `p[j-1]` is `'*'`** (star wildcard)
|
||||||
|
- *Option A*: Use zero occurrences of the preceding element: `dp[i][j] = dp[i][j-2]`
|
||||||
|
- *Option B*: Use one or more occurrences (only if `s[i-1]` matches `p[j-2]`): `dp[i][j] = dp[i-1][j]`
|
||||||
|
- We take the OR of both options
|
||||||
|
|
||||||
|
- **Case 2: `p[j-1]` is `'.'` or a letter**
|
||||||
|
- Check if `s[i-1]` matches `p[j-1]` (either same letter or `'.'`)
|
||||||
|
- If match: `dp[i][j] = dp[i-1][j-1]`
|
||||||
|
- If no match: `dp[i][j] = False`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 4: Return the result**
|
||||||
|
|
||||||
|
- Return `dp[len(s)][len(p)]`
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Mishandling the Star Wildcard
|
||||||
|
description: |
|
||||||
|
The `'*'` doesn't stand alone; it modifies the **preceding character**. A common mistake is treating `*` as "match anything" like in shell globbing.
|
||||||
|
|
||||||
|
In regex matching, `a*` means "zero or more `a`s", not "anything". The pattern `.*` means "zero or more of any character" because `.` matches any single character.
|
||||||
|
|
||||||
|
Always process `*` together with its preceding character as a single unit.
|
||||||
|
wrong_approach: "Treating * as an independent wildcard"
|
||||||
|
correct_approach: "Process * with its preceding character as a unit"
|
||||||
|
|
||||||
|
- title: Forgetting the Zero-Match Case
|
||||||
|
description: |
|
||||||
|
When you see `x*` in the pattern, you might only consider matching one or more `x`s. But `*` means **zero or more**, so you must also consider skipping `x*` entirely.
|
||||||
|
|
||||||
|
For example, matching `s = "aab"` against `p = "c*a*b"`:
|
||||||
|
- `c*` matches zero `c`s
|
||||||
|
- `a*` matches two `a`s
|
||||||
|
- `b` matches `b`
|
||||||
|
|
||||||
|
Missing the zero-match case will cause incorrect results.
|
||||||
|
wrong_approach: "Only considering one or more matches for x*"
|
||||||
|
correct_approach: "Always consider both zero matches (skip) and one-or-more matches"
|
||||||
|
|
||||||
|
- title: Incorrect Base Case for Empty String
|
||||||
|
description: |
|
||||||
|
An empty string `s` can still match certain patterns. For example:
|
||||||
|
- `s = ""` matches `p = "a*"` (zero `a`s)
|
||||||
|
- `s = ""` matches `p = "a*b*c*"` (zero of each)
|
||||||
|
|
||||||
|
You must carefully initialise `dp[0][j]` by checking if `p[0:j]` can match an empty string. This happens when the pattern consists entirely of `x*` pairs.
|
||||||
|
wrong_approach: "Assuming empty string only matches empty pattern"
|
||||||
|
correct_approach: "Check if pattern can reduce to empty via x* zero-matches"
|
||||||
|
|
||||||
|
- title: Off-by-One Errors in Indexing
|
||||||
|
description: |
|
||||||
|
The DP table has dimensions `(len(s)+1) x (len(p)+1)` to handle empty string/pattern cases. When accessing `s[i-1]` or `p[j-1]` from `dp[i][j]`, it's easy to make indexing mistakes.
|
||||||
|
|
||||||
|
Be consistent: `dp[i][j]` represents matching `s[0:i]` with `p[0:j]`, so the "current" characters are `s[i-1]` and `p[j-1]`.
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**DP on two sequences**: When matching/comparing two strings, think of a 2D DP table where `dp[i][j]` represents the answer for prefixes `s[0:i]` and `p[0:j]`"
|
||||||
|
- "**Handle wildcards as units**: `*` modifies its preceding character; process them together"
|
||||||
|
- "**Consider all branches**: The `*` creates branching (zero vs. one-or-more matches); use OR logic to combine possibilities"
|
||||||
|
- "**Foundation for harder problems**: This pattern extends to wildcard matching, edit distance, and other two-string DP problems"
|
||||||
|
|
||||||
|
time_complexity: "O(m * n). We fill a 2D table of size `(len(s)+1) x (len(p)+1)`, and each cell takes O(1) time."
|
||||||
|
space_complexity: "O(m * n). We use a 2D DP table. This can be optimised to O(n) using rolling arrays since we only need the previous row."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Dynamic Programming (Bottom-Up)
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def is_match(s: str, p: str) -> bool:
|
||||||
|
m, n = len(s), len(p)
|
||||||
|
# dp[i][j] = True if s[0:i] matches p[0:j]
|
||||||
|
dp = [[False] * (n + 1) for _ in range(m + 1)]
|
||||||
|
|
||||||
|
# Base case: empty string matches empty pattern
|
||||||
|
dp[0][0] = True
|
||||||
|
|
||||||
|
# Base case: empty string can match patterns like a*, a*b*, etc.
|
||||||
|
for j in range(2, n + 1):
|
||||||
|
# If current char is *, we can use zero occurrences of preceding char
|
||||||
|
if p[j - 1] == '*':
|
||||||
|
dp[0][j] = dp[0][j - 2]
|
||||||
|
|
||||||
|
# Fill the DP table
|
||||||
|
for i in range(1, m + 1):
|
||||||
|
for j in range(1, n + 1):
|
||||||
|
if p[j - 1] == '*':
|
||||||
|
# Option 1: use zero occurrences of preceding element
|
||||||
|
dp[i][j] = dp[i][j - 2]
|
||||||
|
|
||||||
|
# Option 2: use one or more (if current char matches preceding pattern char)
|
||||||
|
if p[j - 2] == '.' or p[j - 2] == s[i - 1]:
|
||||||
|
dp[i][j] = dp[i][j] or dp[i - 1][j]
|
||||||
|
|
||||||
|
elif p[j - 1] == '.' or p[j - 1] == s[i - 1]:
|
||||||
|
# Direct match: current chars match
|
||||||
|
dp[i][j] = dp[i - 1][j - 1]
|
||||||
|
# else: dp[i][j] remains False (no match)
|
||||||
|
|
||||||
|
return dp[m][n]
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(m * n) — We fill each cell of the `(m+1) x (n+1)` table exactly once.
|
||||||
|
|
||||||
|
**Space Complexity:** O(m * n) — We store the entire DP table.
|
||||||
|
|
||||||
|
This bottom-up approach builds the solution from smaller subproblems. The key transitions handle the `*` wildcard by considering both zero matches (skip) and one-or-more matches (consume and stay).
|
||||||
|
|
||||||
|
- approach_name: Recursion with Memoisation
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def is_match(s: str, p: str) -> bool:
|
||||||
|
memo = {}
|
||||||
|
|
||||||
|
def dp(i: int, j: int) -> bool:
|
||||||
|
"""Check if s[i:] matches p[j:]"""
|
||||||
|
if (i, j) in memo:
|
||||||
|
return memo[(i, j)]
|
||||||
|
|
||||||
|
# Base case: pattern exhausted
|
||||||
|
if j == len(p):
|
||||||
|
return i == len(s)
|
||||||
|
|
||||||
|
# Check if first characters match
|
||||||
|
first_match = i < len(s) and (p[j] == s[i] or p[j] == '.')
|
||||||
|
|
||||||
|
# Handle star wildcard
|
||||||
|
if j + 1 < len(p) and p[j + 1] == '*':
|
||||||
|
# Option 1: skip x* (zero occurrences)
|
||||||
|
# Option 2: use x* (if first char matches, consume it)
|
||||||
|
result = dp(i, j + 2) or (first_match and dp(i + 1, j))
|
||||||
|
else:
|
||||||
|
# No star: must match current char and recurse
|
||||||
|
result = first_match and dp(i + 1, j + 1)
|
||||||
|
|
||||||
|
memo[(i, j)] = result
|
||||||
|
return result
|
||||||
|
|
||||||
|
return dp(0, 0)
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(m * n) — Each unique `(i, j)` state is computed once and cached.
|
||||||
|
|
||||||
|
**Space Complexity:** O(m * n) — For the memoisation cache, plus O(m + n) recursion stack depth.
|
||||||
|
|
||||||
|
This top-down approach directly translates the recursive thinking. The memoisation dictionary prevents redundant computation of overlapping subproblems.
|
||||||
|
|
||||||
|
- approach_name: Recursion (Brute Force)
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def is_match(s: str, p: str) -> bool:
|
||||||
|
def dp(i: int, j: int) -> bool:
|
||||||
|
"""Check if s[i:] matches p[j:]"""
|
||||||
|
# Base case: pattern exhausted
|
||||||
|
if j == len(p):
|
||||||
|
return i == len(s)
|
||||||
|
|
||||||
|
# Check if first characters match
|
||||||
|
first_match = i < len(s) and (p[j] == s[i] or p[j] == '.')
|
||||||
|
|
||||||
|
# Handle star wildcard
|
||||||
|
if j + 1 < len(p) and p[j + 1] == '*':
|
||||||
|
# Option 1: skip x* (zero occurrences)
|
||||||
|
# Option 2: use x* (if first char matches, consume it)
|
||||||
|
return dp(i, j + 2) or (first_match and dp(i + 1, j))
|
||||||
|
else:
|
||||||
|
# No star: must match current char and recurse
|
||||||
|
return first_match and dp(i + 1, j + 1)
|
||||||
|
|
||||||
|
return dp(0, 0)
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(2^(m+n)) in the worst case — Without memoisation, the same subproblems are recomputed exponentially many times.
|
||||||
|
|
||||||
|
**Space Complexity:** O(m + n) — Recursion stack depth.
|
||||||
|
|
||||||
|
This naive recursive solution is correct but extremely slow. Patterns with many `*` wildcards cause exponential branching. Included to show why memoisation is essential.
|
||||||
190
backend/data/questions/remove-duplicates-from-sorted-array.yaml
Normal file
190
backend/data/questions/remove-duplicates-from-sorted-array.yaml
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
title: Remove Duplicates from Sorted Array
|
||||||
|
slug: remove-duplicates-from-sorted-array
|
||||||
|
difficulty: easy
|
||||||
|
leetcode_id: 26
|
||||||
|
leetcode_url: https://leetcode.com/problems/remove-duplicates-from-sorted-array/
|
||||||
|
categories:
|
||||||
|
- arrays
|
||||||
|
- two-pointers
|
||||||
|
patterns:
|
||||||
|
- two-pointers
|
||||||
|
|
||||||
|
description: |
|
||||||
|
Given an integer array `nums` sorted in **non-decreasing order**, remove the duplicates *in-place* such that each unique element appears only **once**. The **relative order** of the elements should be kept the **same**.
|
||||||
|
|
||||||
|
Consider the number of unique elements in `nums` to be `k`. After removing duplicates, return the number of unique elements `k`.
|
||||||
|
|
||||||
|
The first `k` elements of `nums` should contain the unique numbers in **sorted order**. The remaining elements beyond index `k - 1` can be ignored.
|
||||||
|
|
||||||
|
**Custom Judge:**
|
||||||
|
|
||||||
|
The judge will test your solution with the following code:
|
||||||
|
|
||||||
|
```java
|
||||||
|
int[] nums = [...]; // Input array
|
||||||
|
int[] expectedNums = [...]; // The expected answer with correct length
|
||||||
|
|
||||||
|
int k = removeDuplicates(nums); // Calls your implementation
|
||||||
|
|
||||||
|
assert k == expectedNums.length;
|
||||||
|
for (int i = 0; i < k; i++) {
|
||||||
|
assert nums[i] == expectedNums[i];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If all assertions pass, then your solution will be **accepted**.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `1 <= nums.length <= 3 * 10^4`
|
||||||
|
- `-100 <= nums[i] <= 100`
|
||||||
|
- `nums` is sorted in **non-decreasing** order
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: "nums = [1,1,2]"
|
||||||
|
output: "2, nums = [1,2,_]"
|
||||||
|
explanation: "Your function should return k = 2, with the first two elements of nums being 1 and 2 respectively. It does not matter what you leave beyond the returned k (hence they are underscores)."
|
||||||
|
- input: "nums = [0,0,1,1,1,2,2,3,3,4]"
|
||||||
|
output: "5, nums = [0,1,2,3,4,_,_,_,_,_]"
|
||||||
|
explanation: "Your function should return k = 5, with the first five elements of nums being 0, 1, 2, 3, and 4 respectively. It does not matter what you leave beyond the returned k (hence they are underscores)."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Imagine you're organising a bookshelf where books are already sorted alphabetically, but some titles appear multiple times. You want to keep only one copy of each book while maintaining the sorted order.
|
||||||
|
|
||||||
|
The key insight is that the array is **already sorted**. This means all duplicates of a value are grouped together consecutively. You don't need to search the entire array to find duplicates — you only need to compare adjacent elements.
|
||||||
|
|
||||||
|
Think of it like this: use two pointers working together. One pointer (`write_index`) marks where the next unique element should be written. The other pointer (`read_index`) scans through the array looking for new unique values. When you find a value different from the last unique one, you copy it to the write position and advance.
|
||||||
|
|
||||||
|
Since the array is sorted, if `nums[i] != nums[i-1]`, then `nums[i]` is definitely a new unique value that hasn't appeared before in our result.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using the **Two Pointers** technique:
|
||||||
|
|
||||||
|
**Step 1: Handle edge case**
|
||||||
|
|
||||||
|
- If the array has 0 or 1 elements, return the length directly — no duplicates possible
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Initialise write pointer**
|
||||||
|
|
||||||
|
- `write_index`: Set to `1` because the first element is always unique (nothing to compare it against)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Iterate with read pointer**
|
||||||
|
|
||||||
|
- Start `read_index` at `1` and scan through the array
|
||||||
|
- For each element, compare `nums[read_index]` with `nums[write_index - 1]` (the last written unique value)
|
||||||
|
- If they differ, we found a new unique element:
|
||||||
|
- Copy `nums[read_index]` to `nums[write_index]`
|
||||||
|
- Increment `write_index`
|
||||||
|
- If they're the same, it's a duplicate — skip it by just incrementing `read_index`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 4: Return the count**
|
||||||
|
|
||||||
|
- Return `write_index` which equals the number of unique elements
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
This works because we're essentially partitioning the array: the first `write_index` positions contain unique values, and everything after can be ignored.
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Using Extra Space
|
||||||
|
description: |
|
||||||
|
A common instinct is to create a new array or use a set to track seen elements:
|
||||||
|
|
||||||
|
```python
|
||||||
|
seen = set()
|
||||||
|
result = []
|
||||||
|
for num in nums:
|
||||||
|
if num not in seen:
|
||||||
|
seen.add(num)
|
||||||
|
result.append(num)
|
||||||
|
```
|
||||||
|
|
||||||
|
While this works logically, it uses **O(n) extra space**, violating the in-place requirement. The problem specifically asks you to modify the original array using only O(1) extra space.
|
||||||
|
wrong_approach: "Using a set or auxiliary array"
|
||||||
|
correct_approach: "Two pointers modifying array in-place"
|
||||||
|
|
||||||
|
- title: Comparing Wrong Elements
|
||||||
|
description: |
|
||||||
|
When checking for duplicates, compare the current element with the **last written unique element**, not the previous element in the original array.
|
||||||
|
|
||||||
|
For example, with `[1, 1, 1, 2]`:
|
||||||
|
- If you compare `nums[2]` with `nums[1]`, they're both `1`, so you skip — correct so far
|
||||||
|
- But if your write_index is at 1 and you compare `nums[3]` with `nums[2]`, you get `2 != 1` — but you should compare with `nums[write_index - 1]`
|
||||||
|
|
||||||
|
The safest approach: always compare with `nums[write_index - 1]` to check against the last confirmed unique value.
|
||||||
|
wrong_approach: "Comparing with nums[i-1] in original array"
|
||||||
|
correct_approach: "Comparing with nums[write_index - 1]"
|
||||||
|
|
||||||
|
- title: Off-by-One Errors
|
||||||
|
description: |
|
||||||
|
Starting `write_index` at `0` instead of `1` leads to overwriting the first element incorrectly, or missing it entirely in your count.
|
||||||
|
|
||||||
|
Remember: the first element is automatically unique. Start writing from index `1`, and your final answer is the value of `write_index`, not `write_index - 1`.
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Two pointers for in-place modification**: One pointer tracks where to write, the other scans for new values — a classic pattern for array manipulation without extra space"
|
||||||
|
- "**Sorted arrays simplify duplicate detection**: Duplicates are always adjacent, so a single comparison with the previous unique element is sufficient"
|
||||||
|
- "**Foundation for harder problems**: This technique extends to problems like *Remove Duplicates from Sorted Array II* (allow up to 2 duplicates) and *Remove Element*"
|
||||||
|
- "**Read-write pointer pattern**: This same pattern applies whenever you need to selectively keep elements while modifying an array in-place"
|
||||||
|
|
||||||
|
time_complexity: "O(n). We traverse the array exactly once with two pointers, performing constant-time operations at each step."
|
||||||
|
space_complexity: "O(1). We only use two integer variables (`write_index` and `read_index`) regardless of input size — the modification happens in-place."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Two Pointers
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def remove_duplicates(nums: list[int]) -> int:
|
||||||
|
# Edge case: empty or single-element array
|
||||||
|
if len(nums) <= 1:
|
||||||
|
return len(nums)
|
||||||
|
|
||||||
|
# First element is always unique, start writing from index 1
|
||||||
|
write_index = 1
|
||||||
|
|
||||||
|
# Scan through array starting from second element
|
||||||
|
for read_index in range(1, len(nums)):
|
||||||
|
# Found a new unique value (different from last written)
|
||||||
|
if nums[read_index] != nums[write_index - 1]:
|
||||||
|
# Copy it to the write position
|
||||||
|
nums[write_index] = nums[read_index]
|
||||||
|
# Move write pointer forward
|
||||||
|
write_index += 1
|
||||||
|
|
||||||
|
# write_index equals the count of unique elements
|
||||||
|
return write_index
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — Single pass through the array.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — Only two integer pointers used.
|
||||||
|
|
||||||
|
The read pointer scans every element once, while the write pointer only advances when we find a unique value. Since the array is sorted, we only need to compare with the most recently written element to detect duplicates.
|
||||||
|
|
||||||
|
- approach_name: Using Set (Extra Space)
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def remove_duplicates(nums: list[int]) -> int:
|
||||||
|
# Track unique elements we've seen
|
||||||
|
seen = set()
|
||||||
|
write_index = 0
|
||||||
|
|
||||||
|
for num in nums:
|
||||||
|
# Only keep elements we haven't seen before
|
||||||
|
if num not in seen:
|
||||||
|
seen.add(num)
|
||||||
|
nums[write_index] = num
|
||||||
|
write_index += 1
|
||||||
|
|
||||||
|
return write_index
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — Single pass with O(1) set operations.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n) — The set stores up to n unique elements.
|
||||||
|
|
||||||
|
While this achieves the same result, it violates the O(1) space constraint. It's included to illustrate how the sorted property of the input allows us to eliminate the need for a set entirely. If the array were unsorted, we'd need this approach or sorting first.
|
||||||
179
backend/data/questions/remove-element.yaml
Normal file
179
backend/data/questions/remove-element.yaml
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
title: Remove Element
|
||||||
|
slug: remove-element
|
||||||
|
difficulty: easy
|
||||||
|
leetcode_id: 27
|
||||||
|
leetcode_url: https://leetcode.com/problems/remove-element/
|
||||||
|
categories:
|
||||||
|
- arrays
|
||||||
|
- two-pointers
|
||||||
|
patterns:
|
||||||
|
- two-pointers
|
||||||
|
|
||||||
|
description: |
|
||||||
|
Given an integer array `nums` and an integer `val`, remove all occurrences of `val` in `nums` **in-place**. The order of the elements may be changed. Then return *the number of elements in* `nums` *which are not equal to* `val`.
|
||||||
|
|
||||||
|
Consider the number of elements in `nums` which are not equal to `val` be `k`, to get accepted, you need to do the following things:
|
||||||
|
|
||||||
|
- Change the array `nums` such that the first `k` elements of `nums` contain the elements which are not equal to `val`. The remaining elements of `nums` are not important as well as the size of `nums`.
|
||||||
|
- Return `k`.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `0 <= nums.length <= 100`
|
||||||
|
- `0 <= nums[i] <= 50`
|
||||||
|
- `0 <= val <= 100`
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: "nums = [3,2,2,3], val = 3"
|
||||||
|
output: "2, nums = [2,2,_,_]"
|
||||||
|
explanation: "Your function should return k = 2, with the first two elements of nums being 2. It does not matter what you leave beyond the returned k (hence they are underscores)."
|
||||||
|
- input: "nums = [0,1,2,2,3,0,4,2], val = 2"
|
||||||
|
output: "5, nums = [0,1,4,0,3,_,_,_]"
|
||||||
|
explanation: "Your function should return k = 5, with the first five elements of nums containing 0, 0, 1, 3, and 4. Note that the five elements can be returned in any order. It does not matter what you leave beyond the returned k (hence they are underscores)."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Imagine you have a row of boxes, and you need to push all the unwanted boxes to the end while keeping the good boxes at the front.
|
||||||
|
|
||||||
|
The key insight is that we don't actually need to "delete" elements — we just need to **overwrite** them with good values and keep track of how many good values we have. Think of it like reorganising a bookshelf: instead of removing books you don't want, you simply shift the books you want to keep toward one end.
|
||||||
|
|
||||||
|
Since the problem allows the remaining elements to be in any order, we have flexibility in how we approach this. We can use a **write pointer** that tracks where the next "good" element should go. Every time we find an element that isn't `val`, we write it to the position indicated by our write pointer and advance the pointer.
|
||||||
|
|
||||||
|
This is the classic **two-pointer technique**: one pointer (`i`) scans through all elements, while another pointer (`k`) marks where to place elements we want to keep.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using the **Two Pointers** technique:
|
||||||
|
|
||||||
|
**Step 1: Initialise the write pointer**
|
||||||
|
|
||||||
|
- `k`: Set to `0` — this tracks the position where the next non-`val` element should be placed
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Iterate through the array**
|
||||||
|
|
||||||
|
- Use a read pointer `i` to scan each element from left to right
|
||||||
|
- If `nums[i] != val`, it's a "keeper" — copy it to position `k` and increment `k`
|
||||||
|
- If `nums[i] == val`, skip it (don't increment `k`)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Return the count**
|
||||||
|
|
||||||
|
- After processing all elements, `k` equals the number of elements that are not `val`
|
||||||
|
- The first `k` positions of `nums` now contain all the non-`val` elements
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
This works because `k` is always less than or equal to `i`. We're essentially compacting the array by overwriting positions with good values as we find them.
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Creating a New Array
|
||||||
|
description: |
|
||||||
|
A common mistake is to create a new list and copy non-`val` elements into it:
|
||||||
|
|
||||||
|
```python
|
||||||
|
result = [x for x in nums if x != val]
|
||||||
|
```
|
||||||
|
|
||||||
|
While this produces the correct values, it uses **O(n) extra space** and doesn't modify `nums` in-place. The problem explicitly requires in-place modification.
|
||||||
|
wrong_approach: "Creating a new array with list comprehension"
|
||||||
|
correct_approach: "Modify nums in-place using two pointers"
|
||||||
|
|
||||||
|
- title: Using remove() or del in a Loop
|
||||||
|
description: |
|
||||||
|
Another tempting approach is to iterate and remove elements:
|
||||||
|
|
||||||
|
```python
|
||||||
|
for x in nums:
|
||||||
|
if x == val:
|
||||||
|
nums.remove(val)
|
||||||
|
```
|
||||||
|
|
||||||
|
This has two problems:
|
||||||
|
1. **Modifying a list while iterating** causes elements to be skipped
|
||||||
|
2. `remove()` is O(n) for each call, making the total complexity O(n²)
|
||||||
|
wrong_approach: "Using remove() or del while iterating"
|
||||||
|
correct_approach: "Use two pointers to avoid modification during iteration"
|
||||||
|
|
||||||
|
- title: Forgetting to Handle Empty Arrays
|
||||||
|
description: |
|
||||||
|
With `nums.length` potentially being `0`, ensure your solution handles empty arrays gracefully. The two-pointer approach naturally handles this — the loop simply doesn't execute, and we return `k = 0`.
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Two-pointer compaction**: Use a slow 'write' pointer and a fast 'read' pointer to compact elements in-place"
|
||||||
|
- "**In-place modification**: When order doesn't matter, overwriting is simpler than shifting or swapping"
|
||||||
|
- "**Foundation for similar problems**: This pattern applies to Remove Duplicates from Sorted Array, Move Zeroes, and other array compaction problems"
|
||||||
|
- "**O(1) space discipline**: In-place algorithms are essential when memory is constrained or when the problem explicitly requires it"
|
||||||
|
|
||||||
|
time_complexity: "O(n). We traverse the array once with the read pointer, performing O(1) work at each step."
|
||||||
|
space_complexity: "O(1). We only use a single integer variable `k` regardless of input size."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Two Pointers
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def remove_element(nums: list[int], val: int) -> int:
|
||||||
|
# k tracks where to place the next non-val element
|
||||||
|
k = 0
|
||||||
|
|
||||||
|
# Scan through every element
|
||||||
|
for i in range(len(nums)):
|
||||||
|
# If current element is not the value to remove
|
||||||
|
if nums[i] != val:
|
||||||
|
# Place it at position k and advance k
|
||||||
|
nums[k] = nums[i]
|
||||||
|
k += 1
|
||||||
|
|
||||||
|
# k is now the count of elements that are not val
|
||||||
|
return k
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — Single pass through the array.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — Only one variable `k` used.
|
||||||
|
|
||||||
|
The read pointer `i` examines each element. When we find a non-`val` element, we copy it to position `k` and increment `k`. Since `k <= i` always, we never overwrite an element we haven't yet examined.
|
||||||
|
|
||||||
|
- approach_name: Two Pointers (Swap from End)
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def remove_element(nums: list[int], val: int) -> int:
|
||||||
|
# Two pointers: left starts at beginning, right at end
|
||||||
|
left = 0
|
||||||
|
right = len(nums) - 1
|
||||||
|
|
||||||
|
while left <= right:
|
||||||
|
if nums[left] == val:
|
||||||
|
# Swap with element from the end
|
||||||
|
nums[left] = nums[right]
|
||||||
|
right -= 1
|
||||||
|
# Don't increment left - need to check swapped element
|
||||||
|
else:
|
||||||
|
left += 1
|
||||||
|
|
||||||
|
# left is now the count of non-val elements
|
||||||
|
return left
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — Each element is examined at most once.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — Only two pointers used.
|
||||||
|
|
||||||
|
This variant is more efficient when `val` is rare. Instead of copying every non-`val` element, we only swap when we encounter `val`. We replace it with an element from the end and shrink the valid range. This minimises the number of writes.
|
||||||
|
|
||||||
|
- approach_name: Brute Force (Extra Space)
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def remove_element(nums: list[int], val: int) -> int:
|
||||||
|
# Create a list of elements to keep
|
||||||
|
keep = [x for x in nums if x != val]
|
||||||
|
|
||||||
|
# Copy back to original array
|
||||||
|
for i, x in enumerate(keep):
|
||||||
|
nums[i] = x
|
||||||
|
|
||||||
|
return len(keep)
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — Two passes through the array.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n) — Extra list to store non-val elements.
|
||||||
|
|
||||||
|
This approach works but violates the in-place requirement. It uses O(n) extra space for the temporary list. Included to illustrate why the two-pointer approach is preferred when in-place modification is required.
|
||||||
186
backend/data/questions/remove-nth-node-from-end-of-list.yaml
Normal file
186
backend/data/questions/remove-nth-node-from-end-of-list.yaml
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
title: Remove Nth Node From End of List
|
||||||
|
slug: remove-nth-node-from-end-of-list
|
||||||
|
difficulty: medium
|
||||||
|
leetcode_id: 19
|
||||||
|
leetcode_url: https://leetcode.com/problems/remove-nth-node-from-end-of-list/
|
||||||
|
categories:
|
||||||
|
- linked-lists
|
||||||
|
- two-pointers
|
||||||
|
patterns:
|
||||||
|
- two-pointers
|
||||||
|
- fast-slow-pointers
|
||||||
|
|
||||||
|
description: |
|
||||||
|
Given the `head` of a linked list, remove the `n`<sup>th</sup> node from the end of the list and return its head.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- The number of nodes in the list is `sz`
|
||||||
|
- `1 <= sz <= 30`
|
||||||
|
- `0 <= Node.val <= 100`
|
||||||
|
- `1 <= n <= sz`
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: "head = [1,2,3,4,5], n = 2"
|
||||||
|
output: "[1,2,3,5]"
|
||||||
|
explanation: "The 2nd node from the end is 4. After removing it, the list becomes [1,2,3,5]."
|
||||||
|
- input: "head = [1], n = 1"
|
||||||
|
output: "[]"
|
||||||
|
explanation: "The list has only one node, and we remove it. The result is an empty list."
|
||||||
|
- input: "head = [1,2], n = 1"
|
||||||
|
output: "[1]"
|
||||||
|
explanation: "The 1st node from the end is 2. After removing it, the list becomes [1]."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
The challenge here is that we're counting from the *end* of the list, but linked lists only allow forward traversal. How do we know which node is the n<sup>th</sup> from the end without first knowing the total length?
|
||||||
|
|
||||||
|
Think of it like this: imagine two people walking along the same path at the same speed. If the first person gets a **head start of exactly `n` steps**, then when they reach the end, the second person will be exactly `n` steps behind — standing at the n<sup>th</sup> position from the end.
|
||||||
|
|
||||||
|
This is the essence of the **two-pointer technique** for linked lists. We use two pointers: a `fast` pointer that advances `n` steps ahead, and a `slow` pointer that starts at the beginning. When we move both pointers together one step at a time, by the time `fast` reaches the end, `slow` will be pointing to the node just *before* the one we want to remove.
|
||||||
|
|
||||||
|
The key insight is that the **gap between the two pointers stays constant** throughout the traversal. This gap acts as our "measuring stick" to find the target node in a single pass.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using the **Two-Pointer (Fast and Slow) Approach** with a dummy node:
|
||||||
|
|
||||||
|
**Step 1: Create a dummy node**
|
||||||
|
|
||||||
|
- Create a `dummy` node that points to `head`
|
||||||
|
- This handles edge cases where we need to remove the first node
|
||||||
|
- Initialise both `slow` and `fast` pointers to the dummy node
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Advance the fast pointer**
|
||||||
|
|
||||||
|
- Move `fast` forward by `n + 1` steps
|
||||||
|
- This creates a gap of `n + 1` nodes between `slow` and `fast`
|
||||||
|
- The extra step ensures `slow` stops at the node *before* the target (so we can delete it)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Move both pointers together**
|
||||||
|
|
||||||
|
- Move `slow` and `fast` one step at a time until `fast` reaches the end (`None`)
|
||||||
|
- When `fast` is `None`, `slow` is pointing to the node immediately before the one to remove
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 4: Remove the target node**
|
||||||
|
|
||||||
|
- Skip over the target node: `slow.next = slow.next.next`
|
||||||
|
- This effectively removes the n<sup>th</sup> node from the end
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 5: Return the new head**
|
||||||
|
|
||||||
|
- Return `dummy.next` (not `head`, as the original head might have been removed)
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Forgetting the Dummy Node
|
||||||
|
description: |
|
||||||
|
When removing the **first node** of the list (n equals the list length), there is no "previous node" to update.
|
||||||
|
|
||||||
|
For example, with `[1,2,3]` and `n = 3`, we need to remove node `1`. Without a dummy node, we'd have no way to update what points to the head.
|
||||||
|
|
||||||
|
Using a dummy node that points to `head` means `slow` can stop at the dummy and still correctly execute `slow.next = slow.next.next` to remove the first real node.
|
||||||
|
wrong_approach: "Starting slow at head directly"
|
||||||
|
correct_approach: "Use a dummy node pointing to head, start slow at dummy"
|
||||||
|
|
||||||
|
- title: Off-by-One Error in Gap
|
||||||
|
description: |
|
||||||
|
If you advance `fast` by only `n` steps (instead of `n + 1`), `slow` will end up pointing *at* the target node rather than the node before it.
|
||||||
|
|
||||||
|
Since we need to modify the `next` pointer of the node *before* the target, we must ensure `slow` stops one position earlier.
|
||||||
|
|
||||||
|
Moving `fast` by `n + 1` steps creates the correct gap so `slow.next` is the node to remove.
|
||||||
|
wrong_approach: "Advance fast by n steps"
|
||||||
|
correct_approach: "Advance fast by n + 1 steps"
|
||||||
|
|
||||||
|
- title: Two-Pass Approach
|
||||||
|
description: |
|
||||||
|
A naive solution counts all nodes first to find the list length, then traverses again to find the target node. While correct, this requires **two passes** through the list.
|
||||||
|
|
||||||
|
The two-pointer technique achieves the same result in a **single pass** by maintaining a fixed gap between pointers. This is more elegant and meets the follow-up challenge.
|
||||||
|
wrong_approach: "Count nodes first, then traverse again"
|
||||||
|
correct_approach: "Single pass with two pointers maintaining n-step gap"
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Two-pointer gap technique**: Maintaining a fixed gap between pointers lets you measure distances from the end in a single pass"
|
||||||
|
- "**Dummy node pattern**: A dummy node simplifies edge cases when the head itself might be modified or removed"
|
||||||
|
- "**Off-by-one awareness**: When deleting a node, you need access to its predecessor, so adjust your gap accordingly"
|
||||||
|
- "**Foundation for linked list problems**: This pattern extends to finding the middle node, detecting cycles, and other linked list operations"
|
||||||
|
|
||||||
|
time_complexity: "O(n). We traverse the list exactly once with two pointers."
|
||||||
|
space_complexity: "O(1). We only use a constant number of pointers (`dummy`, `slow`, `fast`) regardless of list size."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Two Pointers with Dummy Node
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
class ListNode:
|
||||||
|
def __init__(self, val=0, next=None):
|
||||||
|
self.val = val
|
||||||
|
self.next = next
|
||||||
|
|
||||||
|
def remove_nth_from_end(head: ListNode, n: int) -> ListNode:
|
||||||
|
# Dummy node handles edge case of removing the head
|
||||||
|
dummy = ListNode(0, head)
|
||||||
|
slow = dummy
|
||||||
|
fast = dummy
|
||||||
|
|
||||||
|
# Advance fast pointer by n + 1 steps to create the gap
|
||||||
|
for _ in range(n + 1):
|
||||||
|
fast = fast.next
|
||||||
|
|
||||||
|
# Move both pointers until fast reaches the end
|
||||||
|
while fast is not None:
|
||||||
|
slow = slow.next
|
||||||
|
fast = fast.next
|
||||||
|
|
||||||
|
# slow.next is the node to remove; skip over it
|
||||||
|
slow.next = slow.next.next
|
||||||
|
|
||||||
|
# Return the new head (dummy.next in case head was removed)
|
||||||
|
return dummy.next
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — Single pass through the list.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — Only three pointers used.
|
||||||
|
|
||||||
|
We create a gap of `n + 1` between the two pointers. When `fast` reaches the end, `slow` is positioned at the node before the target. We then simply skip over the target node to remove it from the list.
|
||||||
|
|
||||||
|
- approach_name: Two Pass (Count Length)
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def remove_nth_from_end(head: ListNode, n: int) -> ListNode:
|
||||||
|
# First pass: count total nodes
|
||||||
|
length = 0
|
||||||
|
current = head
|
||||||
|
while current:
|
||||||
|
length += 1
|
||||||
|
current = current.next
|
||||||
|
|
||||||
|
# Calculate position from the start (0-indexed)
|
||||||
|
target_index = length - n
|
||||||
|
|
||||||
|
# Edge case: removing the head
|
||||||
|
if target_index == 0:
|
||||||
|
return head.next
|
||||||
|
|
||||||
|
# Second pass: find the node before the target
|
||||||
|
current = head
|
||||||
|
for _ in range(target_index - 1):
|
||||||
|
current = current.next
|
||||||
|
|
||||||
|
# Remove the target node
|
||||||
|
current.next = current.next.next
|
||||||
|
|
||||||
|
return head
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — Two passes through the list.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — Only uses a counter and pointer.
|
||||||
|
|
||||||
|
This approach first counts the total number of nodes, calculates the position from the start, then traverses again to find and remove the target. While correct and still O(n), it requires two passes instead of one. The two-pointer approach is more elegant and solves the follow-up challenge.
|
||||||
227
backend/data/questions/reorder-list.yaml
Normal file
227
backend/data/questions/reorder-list.yaml
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
title: Reorder List
|
||||||
|
slug: reorder-list
|
||||||
|
difficulty: medium
|
||||||
|
leetcode_id: 143
|
||||||
|
leetcode_url: https://leetcode.com/problems/reorder-list/
|
||||||
|
categories:
|
||||||
|
- linked-lists
|
||||||
|
- two-pointers
|
||||||
|
patterns:
|
||||||
|
- fast-slow-pointers
|
||||||
|
- linkedlist-reversal
|
||||||
|
|
||||||
|
description: |
|
||||||
|
You are given the head of a singly linked-list. The list can be represented as:
|
||||||
|
|
||||||
|
```
|
||||||
|
L0 → L1 → … → Ln-1 → Ln
|
||||||
|
```
|
||||||
|
|
||||||
|
*Reorder the list to be on the following form:*
|
||||||
|
|
||||||
|
```
|
||||||
|
L0 → Ln → L1 → Ln-1 → L2 → Ln-2 → …
|
||||||
|
```
|
||||||
|
|
||||||
|
You may not modify the values in the list's nodes. Only nodes themselves may be changed.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- The number of nodes in the list is in the range `[1, 5 * 10^4]`
|
||||||
|
- `1 <= Node.val <= 1000`
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: "head = [1,2,3,4]"
|
||||||
|
output: "[1,4,2,3]"
|
||||||
|
explanation: "The original list is 1→2→3→4. After reordering: take the first node (1), then the last node (4), then the second node (2), then the second-to-last node (3). Result: 1→4→2→3."
|
||||||
|
- input: "head = [1,2,3,4,5]"
|
||||||
|
output: "[1,5,2,4,3]"
|
||||||
|
explanation: "The original list is 1→2→3→4→5. After reordering: 1 (first), 5 (last), 2 (second), 4 (second-to-last), 3 (middle). Result: 1→5→2→4→3."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Imagine you have a deck of cards numbered 1 through n, laid out in a row. The reordering pattern asks you to interleave from both ends: pick the first card, then the last card, then the second card, then the second-to-last card, and so on until you meet in the middle.
|
||||||
|
|
||||||
|
The challenge with a singly linked list is that you can only traverse forward — you can't easily access the last node or go backwards. So how do we efficiently get to the nodes from the end?
|
||||||
|
|
||||||
|
The key insight is to **split the list in half, reverse the second half, then merge the two halves**. After reversing the second half, both halves start from the "outside" positions we need:
|
||||||
|
- First half: `L0 → L1 → L2 → ...`
|
||||||
|
- Reversed second half: `Ln → Ln-1 → Ln-2 → ...`
|
||||||
|
|
||||||
|
Now we can simply alternate between them, picking one node from each half in turn.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using a **three-step approach** combining classic linked list techniques:
|
||||||
|
|
||||||
|
**Step 1: Find the middle of the list**
|
||||||
|
|
||||||
|
- Use the **fast-slow pointer** technique (also called the tortoise and hare)
|
||||||
|
- `slow` moves one step at a time, `fast` moves two steps
|
||||||
|
- When `fast` reaches the end, `slow` is at the middle
|
||||||
|
- This gives us the split point between the two halves
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Reverse the second half**
|
||||||
|
|
||||||
|
- Starting from the node after `slow`, reverse the linked list in-place
|
||||||
|
- Use three pointers: `prev`, `curr`, and `next`
|
||||||
|
- After reversal, we have the second half pointing backwards from the last node
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Merge the two halves**
|
||||||
|
|
||||||
|
- Alternate nodes from the first half and the reversed second half
|
||||||
|
- `first` pointer walks through the first half
|
||||||
|
- `second` pointer walks through the reversed second half
|
||||||
|
- Interleave by adjusting `next` pointers: first→second→first→second→...
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 4: Return**
|
||||||
|
|
||||||
|
- The modification is done in-place, so no return value is needed
|
||||||
|
- The original `head` now points to the reordered list
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Using O(n) Extra Space
|
||||||
|
description: |
|
||||||
|
A common first approach is to copy all nodes into an array, then rebuild the list by picking from both ends of the array. While this works, it uses **O(n) extra space**.
|
||||||
|
|
||||||
|
The optimal solution reverses the second half in-place, achieving **O(1) space** (only a few pointers).
|
||||||
|
wrong_approach: "Store nodes in array and rebuild"
|
||||||
|
correct_approach: "Reverse second half in-place, then merge"
|
||||||
|
|
||||||
|
- title: Off-by-One Errors in Finding Middle
|
||||||
|
description: |
|
||||||
|
The middle-finding logic needs care, especially for even vs odd length lists:
|
||||||
|
- For `[1,2,3,4]`, we want `slow` at node 2, so second half is `[3,4]`
|
||||||
|
- For `[1,2,3,4,5]`, we want `slow` at node 3, so second half is `[4,5]`
|
||||||
|
|
||||||
|
Using `while fast and fast.next` with `slow` starting at `head` gives the correct split. Be careful not to lose the connection when splitting.
|
||||||
|
wrong_approach: "Inconsistent middle calculation"
|
||||||
|
correct_approach: "Use standard fast-slow with while fast.next and fast.next.next"
|
||||||
|
|
||||||
|
- title: Forgetting to Terminate the First Half
|
||||||
|
description: |
|
||||||
|
After finding the middle, you must set `slow.next = None` to terminate the first half. Otherwise, you'll have cycles or incorrect lengths when merging.
|
||||||
|
|
||||||
|
For example, with `[1,2,3,4]`, after splitting, first half should be `1→2→None` and second half `3→4→None`. Without terminating, first half would still point to 3.
|
||||||
|
wrong_approach: "Not breaking the link at the middle"
|
||||||
|
correct_approach: "Set slow.next = None before reversing"
|
||||||
|
|
||||||
|
- title: Incorrect Merge Order
|
||||||
|
description: |
|
||||||
|
When merging, remember you're alternating: first half node, then second half node. Save the next pointers before rewiring:
|
||||||
|
|
||||||
|
```python
|
||||||
|
tmp1, tmp2 = first.next, second.next
|
||||||
|
first.next = second
|
||||||
|
second.next = tmp1
|
||||||
|
first, second = tmp1, tmp2
|
||||||
|
```
|
||||||
|
|
||||||
|
Failing to save `next` pointers before modifying them leads to lost nodes.
|
||||||
|
wrong_approach: "Modifying next pointers without saving"
|
||||||
|
correct_approach: "Save both next pointers before any rewiring"
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Fast-slow pointer pattern**: Essential technique for finding the middle of a linked list in O(n) time and O(1) space"
|
||||||
|
- "**In-place reversal**: Reversing a linked list with three pointers (`prev`, `curr`, `next`) is a fundamental operation"
|
||||||
|
- "**Decompose complex operations**: Breaking the problem into find-middle, reverse, and merge makes it manageable"
|
||||||
|
- "**Related problems**: This pattern applies to Palindrome Linked List, Sort List, and other problems requiring middle-finding or reversal"
|
||||||
|
|
||||||
|
time_complexity: "O(n). We traverse the list three times: once to find the middle, once to reverse the second half, and once to merge. Each pass is O(n), so overall O(n)."
|
||||||
|
space_complexity: "O(1). We only use a constant number of pointer variables (`slow`, `fast`, `prev`, `curr`, `first`, `second`, etc.), regardless of list size."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Three-Step In-Place
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
class ListNode:
|
||||||
|
def __init__(self, val=0, next=None):
|
||||||
|
self.val = val
|
||||||
|
self.next = next
|
||||||
|
|
||||||
|
def reorder_list(head: ListNode) -> None:
|
||||||
|
if not head or not head.next:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Step 1: Find the middle using fast-slow pointers
|
||||||
|
slow, fast = head, head
|
||||||
|
while fast.next and fast.next.next:
|
||||||
|
slow = slow.next
|
||||||
|
fast = fast.next.next
|
||||||
|
|
||||||
|
# Step 2: Reverse the second half
|
||||||
|
# slow is at the middle, second half starts at slow.next
|
||||||
|
prev, curr = None, slow.next
|
||||||
|
slow.next = None # Terminate the first half
|
||||||
|
|
||||||
|
while curr:
|
||||||
|
next_temp = curr.next # Save next node
|
||||||
|
curr.next = prev # Reverse the pointer
|
||||||
|
prev = curr # Move prev forward
|
||||||
|
curr = next_temp # Move curr forward
|
||||||
|
|
||||||
|
# prev now points to the head of reversed second half
|
||||||
|
|
||||||
|
# Step 3: Merge the two halves
|
||||||
|
first, second = head, prev
|
||||||
|
while second:
|
||||||
|
# Save next pointers
|
||||||
|
tmp1, tmp2 = first.next, second.next
|
||||||
|
# Interleave
|
||||||
|
first.next = second
|
||||||
|
second.next = tmp1
|
||||||
|
# Move to next pair
|
||||||
|
first, second = tmp1, tmp2
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — Three linear passes through the list.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — Only a constant number of pointers used.
|
||||||
|
|
||||||
|
This solution combines three classic linked list operations: finding the middle with fast-slow pointers, reversing a linked list in-place, and merging two lists. Each operation is O(n) time and O(1) space.
|
||||||
|
|
||||||
|
- approach_name: Stack-Based
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
class ListNode:
|
||||||
|
def __init__(self, val=0, next=None):
|
||||||
|
self.val = val
|
||||||
|
self.next = next
|
||||||
|
|
||||||
|
def reorder_list(head: ListNode) -> None:
|
||||||
|
if not head or not head.next:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Store all nodes in a list for random access
|
||||||
|
nodes = []
|
||||||
|
curr = head
|
||||||
|
while curr:
|
||||||
|
nodes.append(curr)
|
||||||
|
curr = curr.next
|
||||||
|
|
||||||
|
# Use two pointers on the array
|
||||||
|
left, right = 0, len(nodes) - 1
|
||||||
|
|
||||||
|
while left < right:
|
||||||
|
# Connect left to right
|
||||||
|
nodes[left].next = nodes[right]
|
||||||
|
left += 1
|
||||||
|
|
||||||
|
if left == right:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Connect right to next left
|
||||||
|
nodes[right].next = nodes[left]
|
||||||
|
right -= 1
|
||||||
|
|
||||||
|
# Terminate the list
|
||||||
|
nodes[left].next = None
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — One pass to collect nodes, one pass to rewire.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n) — Array storing all node references.
|
||||||
|
|
||||||
|
This approach trades space for simplicity. By storing nodes in an array, we gain random access and can easily pick from both ends. While correct, it uses O(n) extra space, making it suboptimal compared to the in-place solution.
|
||||||
236
backend/data/questions/reorganize-string.yaml
Normal file
236
backend/data/questions/reorganize-string.yaml
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
title: Reorganize String
|
||||||
|
slug: reorganize-string
|
||||||
|
difficulty: medium
|
||||||
|
leetcode_id: 767
|
||||||
|
leetcode_url: https://leetcode.com/problems/reorganize-string/
|
||||||
|
categories:
|
||||||
|
- strings
|
||||||
|
- hash-tables
|
||||||
|
- heap
|
||||||
|
patterns:
|
||||||
|
- greedy
|
||||||
|
- heap
|
||||||
|
|
||||||
|
description: |
|
||||||
|
Given a string `s`, rearrange the characters of `s` so that any two adjacent characters are not the same.
|
||||||
|
|
||||||
|
Return *any possible rearrangement of* `s` *or return* `""` *if not possible*.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `1 <= s.length <= 500`
|
||||||
|
- `s` consists of lowercase English letters.
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: 's = "aab"'
|
||||||
|
output: '"aba"'
|
||||||
|
explanation: "We can rearrange 'aab' to 'aba' where no two adjacent characters are the same."
|
||||||
|
- input: 's = "aaab"'
|
||||||
|
output: '""'
|
||||||
|
explanation: "There are 3 'a's but only 1 'b'. Even with optimal placement, we cannot avoid adjacent 'a's: 'a_a_a' needs at least 2 other characters to separate them."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Imagine you have a collection of coloured balls and need to arrange them in a line such that no two balls of the same colour are next to each other.
|
||||||
|
|
||||||
|
The key insight is that this problem is all about **frequency distribution**. If any single character appears too frequently, it becomes impossible to separate all its occurrences with other characters. Specifically, if a character appears more than `(n + 1) / 2` times (where `n` is the string length), there's no valid arrangement.
|
||||||
|
|
||||||
|
Think of it like seating people at a round table where enemies can't sit adjacent: if one group is too large, you simply can't place enough "buffers" between them.
|
||||||
|
|
||||||
|
The greedy strategy is to **always place the most frequent character first**. By continuously choosing the character with the highest remaining count, we ensure we're using up the "problem" characters early while we still have other characters to interleave between them.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using a **Max Heap (Greedy) Approach**:
|
||||||
|
|
||||||
|
**Step 1: Count character frequencies**
|
||||||
|
|
||||||
|
- Use a hash map (or `Counter`) to count occurrences of each character
|
||||||
|
- Check if any character appears more than `(n + 1) / 2` times — if so, return `""` immediately
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Build a max heap**
|
||||||
|
|
||||||
|
- Create a max heap containing `(count, character)` pairs
|
||||||
|
- Python's `heapq` is a min heap, so negate counts to simulate max heap behaviour
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Greedily build the result**
|
||||||
|
|
||||||
|
- Pop the character with the highest frequency from the heap
|
||||||
|
- Append it to the result
|
||||||
|
- If there's a "previous" character waiting to be re-added (with remaining count > 0), push it back onto the heap
|
||||||
|
- Store the current character as "previous" so we don't use it again immediately
|
||||||
|
- Repeat until the heap is empty
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 4: Return the result**
|
||||||
|
|
||||||
|
- Join the result list into a string and return it
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
This greedy approach works because by always placing the most frequent character (that isn't the same as the last placed), we maximise our chances of successfully interleaving all characters.
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Ignoring the Impossibility Check
|
||||||
|
description: |
|
||||||
|
Before attempting to build a solution, you must verify that a valid arrangement is even possible.
|
||||||
|
|
||||||
|
If any character appears more than `(n + 1) / 2` times, it's mathematically impossible to arrange the string. For example, with `"aaab"` (length 4), the character `'a'` appears 3 times but `(4 + 1) / 2 = 2` is the maximum allowed frequency.
|
||||||
|
|
||||||
|
Failing to check this upfront leads to infinite loops or incorrect results.
|
||||||
|
wrong_approach: "Attempting to build without checking frequencies first"
|
||||||
|
correct_approach: "Check max frequency <= (n + 1) / 2 before proceeding"
|
||||||
|
|
||||||
|
- title: Using the Same Character Consecutively
|
||||||
|
description: |
|
||||||
|
A naive greedy approach might always pick the most frequent character, but this fails when that character was just placed.
|
||||||
|
|
||||||
|
For example, with `"aabb"`, always picking the most frequent could give `"aab..."` which already has adjacent duplicates.
|
||||||
|
|
||||||
|
The solution is to track the previously placed character and ensure we don't pick it again until at least one other character has been placed.
|
||||||
|
wrong_approach: "Always pop the max without tracking previous"
|
||||||
|
correct_approach: "Hold back the previous character for one iteration"
|
||||||
|
|
||||||
|
- title: Off-by-One in Frequency Threshold
|
||||||
|
description: |
|
||||||
|
The threshold for impossibility is `(n + 1) / 2`, not `n / 2`. This matters for odd-length strings.
|
||||||
|
|
||||||
|
For `"aab"` (length 3), we have `(3 + 1) / 2 = 2`. Character `'a'` appears exactly 2 times, which is valid: `"aba"`.
|
||||||
|
|
||||||
|
Using `n / 2 = 1` would incorrectly reject valid inputs.
|
||||||
|
wrong_approach: "Using n / 2 as the threshold"
|
||||||
|
correct_approach: "Using (n + 1) / 2 (ceiling of n/2)"
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Frequency analysis first**: Many string rearrangement problems require checking if a solution is possible before attempting construction"
|
||||||
|
- "**Greedy with heap**: When you need to repeatedly pick the 'best' remaining option, a heap provides O(log n) selection"
|
||||||
|
- "**Hold-back pattern**: To avoid consecutive duplicates, temporarily exclude the just-used element from selection"
|
||||||
|
- "**Related problems**: This pattern applies to Task Scheduler (LC 621), Distant Barcodes (LC 1054), and other interleaving problems"
|
||||||
|
|
||||||
|
time_complexity: "O(n log k). We process each of the `n` characters, and each heap operation takes O(log k) where `k` is the number of unique characters (at most 26)."
|
||||||
|
space_complexity: "O(k). We store at most `k` unique characters in the heap and hash map, where `k <= 26` for lowercase English letters."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Max Heap (Greedy)
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
import heapq
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
def reorganize_string(s: str) -> str:
|
||||||
|
# Count frequency of each character
|
||||||
|
counts = Counter(s)
|
||||||
|
n = len(s)
|
||||||
|
|
||||||
|
# Check if reorganization is possible
|
||||||
|
max_count = max(counts.values())
|
||||||
|
if max_count > (n + 1) // 2:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Build max heap (negate counts for max heap behaviour)
|
||||||
|
heap = [(-count, char) for char, count in counts.items()]
|
||||||
|
heapq.heapify(heap)
|
||||||
|
|
||||||
|
result = []
|
||||||
|
prev_count, prev_char = 0, ""
|
||||||
|
|
||||||
|
while heap:
|
||||||
|
# Pop the most frequent character
|
||||||
|
count, char = heapq.heappop(heap)
|
||||||
|
result.append(char)
|
||||||
|
|
||||||
|
# Push back the previous character if it has remaining count
|
||||||
|
if prev_count < 0:
|
||||||
|
heapq.heappush(heap, (prev_count, prev_char))
|
||||||
|
|
||||||
|
# Update previous (decrement count since we used one)
|
||||||
|
prev_count, prev_char = count + 1, char
|
||||||
|
|
||||||
|
return "".join(result)
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n log k) — We process n characters, each with O(log k) heap operations where k <= 26.
|
||||||
|
|
||||||
|
**Space Complexity:** O(k) — The heap and counter store at most k unique characters.
|
||||||
|
|
||||||
|
The key insight is holding back the previously used character for one iteration. This ensures we never place the same character twice in a row. By always choosing the most frequent available character, we maximise our chances of using up high-frequency characters while we have options to interleave.
|
||||||
|
|
||||||
|
- approach_name: Odd-Even Index Placement
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
def reorganize_string(s: str) -> str:
|
||||||
|
counts = Counter(s)
|
||||||
|
n = len(s)
|
||||||
|
|
||||||
|
# Check if reorganization is possible
|
||||||
|
max_count = max(counts.values())
|
||||||
|
if max_count > (n + 1) // 2:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Sort characters by frequency (descending)
|
||||||
|
sorted_chars = sorted(counts.keys(), key=lambda c: -counts[c])
|
||||||
|
|
||||||
|
result = [""] * n
|
||||||
|
idx = 0
|
||||||
|
|
||||||
|
for char in sorted_chars:
|
||||||
|
for _ in range(counts[char]):
|
||||||
|
# Place at current index
|
||||||
|
result[idx] = char
|
||||||
|
# Move to next even index, wrap to odd indices
|
||||||
|
idx += 2
|
||||||
|
if idx >= n:
|
||||||
|
idx = 1
|
||||||
|
|
||||||
|
return "".join(result)
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n + k log k) — Counting takes O(n), sorting k characters takes O(k log k), and placement takes O(n).
|
||||||
|
|
||||||
|
**Space Complexity:** O(n) — We use an array of size n for the result.
|
||||||
|
|
||||||
|
This approach places characters at alternating indices: first fill all even positions (0, 2, 4, ...), then odd positions (1, 3, 5, ...). By placing the most frequent character first, we ensure it gets spread across even indices. Since max frequency is at most `(n + 1) / 2`, we're guaranteed not to overflow into adjacent positions of the same character.
|
||||||
|
|
||||||
|
- approach_name: Brute Force (Backtracking)
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
def reorganize_string(s: str) -> str:
|
||||||
|
counts = Counter(s)
|
||||||
|
|
||||||
|
def backtrack(result: list[str], prev: str) -> bool:
|
||||||
|
# Base case: used all characters
|
||||||
|
if len(result) == len(s):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Try each character
|
||||||
|
for char in counts:
|
||||||
|
if counts[char] > 0 and char != prev:
|
||||||
|
# Choose this character
|
||||||
|
counts[char] -= 1
|
||||||
|
result.append(char)
|
||||||
|
|
||||||
|
# Recurse
|
||||||
|
if backtrack(result, char):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Backtrack
|
||||||
|
result.pop()
|
||||||
|
counts[char] += 1
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
result: list[str] = []
|
||||||
|
if backtrack(result, ""):
|
||||||
|
return "".join(result)
|
||||||
|
return ""
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n! / (c1! * c2! * ...)) — Explores permutations with pruning, still exponential in worst case.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n) — Recursion depth and result array.
|
||||||
|
|
||||||
|
This approach tries all valid arrangements using backtracking. While correct, it's far too slow for larger inputs. Included to illustrate that a greedy approach (choosing most frequent available) is much more efficient than exploring all possibilities.
|
||||||
203
backend/data/questions/reverse-bits.yaml
Normal file
203
backend/data/questions/reverse-bits.yaml
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
title: Reverse Bits
|
||||||
|
slug: reverse-bits
|
||||||
|
difficulty: easy
|
||||||
|
leetcode_id: 190
|
||||||
|
leetcode_url: https://leetcode.com/problems/reverse-bits/
|
||||||
|
categories:
|
||||||
|
- math
|
||||||
|
patterns:
|
||||||
|
- two-pointers
|
||||||
|
|
||||||
|
function_signature: "def reverse_bits(n: int) -> int:"
|
||||||
|
|
||||||
|
test_cases:
|
||||||
|
visible:
|
||||||
|
- input: { n: 43261596 }
|
||||||
|
expected: 964176192
|
||||||
|
- input: { n: 4294967293 }
|
||||||
|
expected: 3221225471
|
||||||
|
- input: { n: 1 }
|
||||||
|
expected: 2147483648
|
||||||
|
hidden:
|
||||||
|
- input: { n: 0 }
|
||||||
|
expected: 0
|
||||||
|
- input: { n: 2 }
|
||||||
|
expected: 1073741824
|
||||||
|
- input: { n: 4294967295 }
|
||||||
|
expected: 4294967295
|
||||||
|
|
||||||
|
description: |
|
||||||
|
Reverse bits of a given 32 bits unsigned integer.
|
||||||
|
|
||||||
|
**Note:**
|
||||||
|
|
||||||
|
- In some languages, such as Java, there is no unsigned integer type. In this case, both input and output will be given as a signed integer type. They should not affect your implementation, as the integer's internal binary representation is the same, whether it is signed or unsigned.
|
||||||
|
- In Java, the compiler represents the signed integers using 2's complement notation. Therefore, in **Example 2**, the input represents the signed integer `-3` and the output represents the signed integer `-1073741825`.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- The input must be a **binary string** of length `32`
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: "n = 00000010100101000001111010011100"
|
||||||
|
output: "964176192 (00111001011110000010100101000000)"
|
||||||
|
explanation: "The input binary string 00000010100101000001111010011100 represents the unsigned integer 43261596, so return 964176192 which its binary representation is 00111001011110000010100101000000."
|
||||||
|
- input: "n = 11111111111111111111111111111101"
|
||||||
|
output: "3221225471 (10111111111111111111111111111111)"
|
||||||
|
explanation: "The input binary string 11111111111111111111111111111101 represents the unsigned integer 4294967293, so return 3221225471 which its binary representation is 10111111111111111111111111111111."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Imagine you have a string of 32 characters and you want to reverse it. The same concept applies to binary digits (bits) — we need to flip the order so the first bit becomes the last, the second becomes second-to-last, and so on.
|
||||||
|
|
||||||
|
Think of it like reading a word backwards: "hello" becomes "olleh". For bits, we're doing the same thing but with 1s and 0s.
|
||||||
|
|
||||||
|
The key insight is that we can build the reversed number **bit by bit**. As we extract each bit from the right side of the input (the least significant bit), we place it on the left side of our result (making it the most significant bit so far). By doing this 32 times, we've effectively reversed all bits.
|
||||||
|
|
||||||
|
Another way to visualise this: imagine two pointers — one at the rightmost bit of the input, one at the leftmost position of the output. We copy bits from input to output while moving both pointers towards each other.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using a **Bit-by-Bit Extraction** approach:
|
||||||
|
|
||||||
|
**Step 1: Initialise the result**
|
||||||
|
|
||||||
|
- `result`: Set to `0` — this will hold our reversed bits
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Process all 32 bits**
|
||||||
|
|
||||||
|
- Loop exactly 32 times (since we're working with 32-bit integers)
|
||||||
|
- In each iteration:
|
||||||
|
- **Left-shift the result** by 1 position to make room for the next bit
|
||||||
|
- **Extract the rightmost bit** of `n` using `n & 1`
|
||||||
|
- **Add this bit** to the result using OR: `result | (n & 1)`
|
||||||
|
- **Right-shift `n`** by 1 to move to the next bit
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Return the result**
|
||||||
|
|
||||||
|
- After 32 iterations, `result` contains all bits in reversed order
|
||||||
|
- Return `result`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
This works because left-shifting the result before adding each new bit ensures that earlier-extracted bits (from the right of `n`) end up on the left of the result.
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Processing Only Until n Becomes Zero
|
||||||
|
description: |
|
||||||
|
A tempting optimisation is to stop the loop when `n` becomes zero, since all remaining bits are 0.
|
||||||
|
|
||||||
|
However, this is **incorrect** because those trailing zeros in the input become leading zeros in the output, which affects the numerical value. For example, if the input has 4 significant bits, stopping early would only reverse those 4 bits instead of all 32.
|
||||||
|
|
||||||
|
Always process exactly 32 bits to ensure correctness.
|
||||||
|
wrong_approach: "while n > 0: process bits"
|
||||||
|
correct_approach: "for i in range(32): process bits"
|
||||||
|
|
||||||
|
- title: Forgetting the Unsigned Nature
|
||||||
|
description: |
|
||||||
|
In languages like Python, integers can be arbitrarily large, but we're specifically dealing with **32-bit unsigned integers**.
|
||||||
|
|
||||||
|
The result should be treated as unsigned, which means values can range from `0` to `2^32 - 1`. In some languages, you may need to handle sign extension or use unsigned types explicitly.
|
||||||
|
|
||||||
|
In Python, this isn't an issue since integers don't overflow, but be aware when translating to other languages.
|
||||||
|
wrong_approach: "Treating result as signed integer in languages with fixed-width types"
|
||||||
|
correct_approach: "Use unsigned integer types or handle masking appropriately"
|
||||||
|
|
||||||
|
- title: Confusing Bit Order
|
||||||
|
description: |
|
||||||
|
It's easy to get confused about which direction to shift. Remember:
|
||||||
|
|
||||||
|
- **Right-shift `n`** to access bits from right to left (LSB first)
|
||||||
|
- **Left-shift `result`** to place bits from left to right (making extracted bits the MSB)
|
||||||
|
|
||||||
|
If you shift in the wrong direction, you'll get incorrect results or infinite loops.
|
||||||
|
wrong_approach: "Left-shifting n or right-shifting result"
|
||||||
|
correct_approach: "Right-shift n to extract, left-shift result to place"
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Bit extraction pattern**: Use `n & 1` to get the rightmost bit, then right-shift to move to the next bit"
|
||||||
|
- "**Building numbers bit by bit**: Left-shift the result before adding each new bit to place bits in the correct position"
|
||||||
|
- "**Fixed iteration count**: When working with fixed-width integers, always process all bits — don't optimise by stopping early"
|
||||||
|
- "**Reversing as two-pointer analogy**: Think of it as two pointers moving in opposite directions — extracting from right, placing on left"
|
||||||
|
|
||||||
|
time_complexity: "O(1). We always perform exactly 32 iterations, regardless of the input value."
|
||||||
|
space_complexity: "O(1). We only use a single variable (`result`) to store the reversed bits."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Bit-by-Bit Extraction
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def reverse_bits(n: int) -> int:
|
||||||
|
result = 0
|
||||||
|
|
||||||
|
# Process all 32 bits
|
||||||
|
for _ in range(32):
|
||||||
|
# Make room for the next bit in result
|
||||||
|
result <<= 1
|
||||||
|
|
||||||
|
# Extract rightmost bit of n and add to result
|
||||||
|
result |= (n & 1)
|
||||||
|
|
||||||
|
# Move to the next bit in n
|
||||||
|
n >>= 1
|
||||||
|
|
||||||
|
return result
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(1) — Fixed 32 iterations regardless of input.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — Only one variable used.
|
||||||
|
|
||||||
|
We iterate through all 32 bits, extracting each bit from the right side of the input and placing it on the left side of the result. The left-shift before each addition ensures proper bit positioning.
|
||||||
|
|
||||||
|
- approach_name: Divide and Conquer (Byte Swap)
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def reverse_bits(n: int) -> int:
|
||||||
|
# Swap adjacent single bits
|
||||||
|
n = ((n & 0x55555555) << 1) | ((n >> 1) & 0x55555555)
|
||||||
|
# Swap adjacent 2-bit pairs
|
||||||
|
n = ((n & 0x33333333) << 2) | ((n >> 2) & 0x33333333)
|
||||||
|
# Swap adjacent 4-bit nibbles
|
||||||
|
n = ((n & 0x0F0F0F0F) << 4) | ((n >> 4) & 0x0F0F0F0F)
|
||||||
|
# Swap adjacent bytes
|
||||||
|
n = ((n & 0x00FF00FF) << 8) | ((n >> 8) & 0x00FF00FF)
|
||||||
|
# Swap 2-byte halves
|
||||||
|
n = (n << 16) | (n >> 16)
|
||||||
|
|
||||||
|
# Mask to 32 bits (needed for Python)
|
||||||
|
return n & 0xFFFFFFFF
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(1) — Fixed number of operations.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — In-place bit manipulation.
|
||||||
|
|
||||||
|
This approach uses divide and conquer to reverse bits in log(32) = 5 steps. We first swap adjacent single bits, then adjacent pairs, then nibbles (4 bits), then bytes, and finally the two 16-bit halves.
|
||||||
|
|
||||||
|
While this has the same asymptotic complexity, it uses fewer operations (5 vs 32) and is more efficient in practice. The magic constants are bit masks that select alternating groups of bits.
|
||||||
|
|
||||||
|
- approach_name: Lookup Table (Follow-up Optimisation)
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
# Precompute reversed values for all bytes (0-255)
|
||||||
|
REVERSE_BYTE = [0] * 256
|
||||||
|
for i in range(256):
|
||||||
|
REVERSE_BYTE[i] = int(f'{i:08b}'[::-1], 2)
|
||||||
|
|
||||||
|
def reverse_bits(n: int) -> int:
|
||||||
|
# Reverse each byte and place in opposite position
|
||||||
|
return (
|
||||||
|
REVERSE_BYTE[n & 0xFF] << 24 |
|
||||||
|
REVERSE_BYTE[(n >> 8) & 0xFF] << 16 |
|
||||||
|
REVERSE_BYTE[(n >> 16) & 0xFF] << 8 |
|
||||||
|
REVERSE_BYTE[(n >> 24) & 0xFF]
|
||||||
|
)
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(1) — Only 4 lookups and bit operations.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — The lookup table is fixed at 256 entries.
|
||||||
|
|
||||||
|
This approach precomputes the reversed value of every possible byte (0-255). To reverse a 32-bit integer, we split it into 4 bytes, look up each reversed byte, and place them in reverse order.
|
||||||
|
|
||||||
|
This is ideal for the follow-up question about repeated calls — the precomputation is done once, and each call becomes extremely fast with just 4 table lookups.
|
||||||
174
backend/data/questions/reverse-integer.yaml
Normal file
174
backend/data/questions/reverse-integer.yaml
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
title: Reverse Integer
|
||||||
|
slug: reverse-integer
|
||||||
|
difficulty: easy
|
||||||
|
leetcode_id: 7
|
||||||
|
leetcode_url: https://leetcode.com/problems/reverse-integer/
|
||||||
|
categories:
|
||||||
|
- math
|
||||||
|
patterns:
|
||||||
|
- greedy
|
||||||
|
|
||||||
|
description: |
|
||||||
|
Given a signed 32-bit integer `x`, return `x` *with its digits reversed*. If reversing `x` causes the value to go outside the signed 32-bit integer range `[-2^31, 2^31 - 1]`, then return `0`.
|
||||||
|
|
||||||
|
**Assume the environment does not allow you to store 64-bit integers (signed or unsigned).**
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `-2^31 <= x <= 2^31 - 1`
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: "x = 123"
|
||||||
|
output: "321"
|
||||||
|
explanation: "The digits of 123 reversed give 321."
|
||||||
|
- input: "x = -123"
|
||||||
|
output: "-321"
|
||||||
|
explanation: "The digits of -123 reversed give -321. The negative sign is preserved."
|
||||||
|
- input: "x = 120"
|
||||||
|
output: "21"
|
||||||
|
explanation: "Trailing zeros in the original number become leading zeros after reversal and are dropped."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Think of reversing an integer like reading a number backwards, digit by digit.
|
||||||
|
|
||||||
|
Imagine you have a stack of cards, each showing one digit of the number. To reverse the number, you'd pick up cards from the original stack one at a time (starting from the rightmost digit) and place them face-up in a new stack. The first card you pick becomes the most significant digit of your new number.
|
||||||
|
|
||||||
|
The key insight is that you can extract digits from the right using the modulo operator (`x % 10` gives the last digit) and build the reversed number by multiplying your accumulated result by 10 before adding each new digit.
|
||||||
|
|
||||||
|
The critical challenge is **overflow detection**. Since we cannot use 64-bit integers, we must check *before* each multiplication whether the operation would overflow the 32-bit signed integer range. This requires checking against `INT_MAX // 10` and `INT_MIN // 10` before multiplying.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this by **extracting digits from the right and building the reversed number**.
|
||||||
|
|
||||||
|
**Step 1: Handle the sign**
|
||||||
|
|
||||||
|
- We can work with the absolute value and restore the sign at the end, or handle negative numbers directly with modulo (Python's modulo handles negatives differently, so we'll track the sign separately)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Extract digits one by one**
|
||||||
|
|
||||||
|
- Use `digit = x % 10` to get the rightmost digit
|
||||||
|
- Use `x = x // 10` to remove that digit from the original number
|
||||||
|
- Repeat until `x` becomes `0`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Build the reversed number with overflow checking**
|
||||||
|
|
||||||
|
- Before multiplying `result` by 10, check if it would overflow:
|
||||||
|
- If `result > INT_MAX // 10`, multiplying would overflow
|
||||||
|
- If `result < INT_MIN // 10`, multiplying would underflow
|
||||||
|
- If `result == INT_MAX // 10` and `digit > 7`, adding the digit would overflow
|
||||||
|
- If `result == INT_MIN // 10` and `digit < -8`, adding the digit would underflow
|
||||||
|
- If safe, compute `result = result * 10 + digit`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 4: Return the result**
|
||||||
|
|
||||||
|
- Return the reversed number, or `0` if overflow was detected
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Ignoring Overflow
|
||||||
|
description: |
|
||||||
|
The most common mistake is forgetting to check for overflow. With 32-bit signed integers, the range is `[-2147483648, 2147483647]`.
|
||||||
|
|
||||||
|
Consider `x = 1534236469`. Reversed, this would be `9646324351`, which exceeds `INT_MAX`. Without overflow checking, you'd get undefined behaviour or incorrect results.
|
||||||
|
|
||||||
|
Always check *before* the operation that could cause overflow, not after.
|
||||||
|
wrong_approach: "Reversing without bounds checking"
|
||||||
|
correct_approach: "Check against INT_MAX // 10 before multiplying"
|
||||||
|
|
||||||
|
- title: Using 64-bit Integers
|
||||||
|
description: |
|
||||||
|
The problem explicitly states you cannot use 64-bit integers. A common "cheat" is to use a `long` or `int64_t` to hold the intermediate result and check bounds at the end.
|
||||||
|
|
||||||
|
While this works in practice, it violates the problem constraints. The correct approach is to detect potential overflow *before* it happens using only 32-bit arithmetic.
|
||||||
|
wrong_approach: "Using long/int64 for intermediate calculations"
|
||||||
|
correct_approach: "Pre-check overflow using INT_MAX // 10"
|
||||||
|
|
||||||
|
- title: Incorrect Handling of Negative Numbers
|
||||||
|
description: |
|
||||||
|
In some languages, the modulo operator behaves differently for negative numbers. For example, in C++, `-123 % 10` gives `-3`, while in Python it gives `7`.
|
||||||
|
|
||||||
|
The safest approach is to track the sign separately and work with the absolute value, then restore the sign at the end.
|
||||||
|
wrong_approach: "Assuming modulo always gives positive results"
|
||||||
|
correct_approach: "Track sign separately or understand language-specific modulo behaviour"
|
||||||
|
|
||||||
|
- title: Edge Case with INT_MIN
|
||||||
|
description: |
|
||||||
|
`INT_MIN` is `-2147483648`, but `abs(INT_MIN)` cannot be represented as a positive 32-bit integer (since `INT_MAX` is only `2147483647`).
|
||||||
|
|
||||||
|
Special handling may be needed when working with the absolute value of `INT_MIN`.
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Digit extraction pattern**: `x % 10` extracts the last digit, `x // 10` removes it — this pattern appears in many number manipulation problems"
|
||||||
|
- "**Overflow prevention**: Always check *before* an operation that could overflow, not after — compare against `INT_MAX // 10`"
|
||||||
|
- "**Building numbers digit by digit**: `result = result * 10 + digit` is the standard way to construct an integer from its digits"
|
||||||
|
- "**Related problems**: This technique extends to palindrome checking, digit sum problems, and number conversion tasks"
|
||||||
|
|
||||||
|
time_complexity: "O(log x). The number of digits in `x` is proportional to `log10(x)`, and we process each digit once."
|
||||||
|
space_complexity: "O(1). We only use a constant number of variables regardless of the input size."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Mathematical Digit Extraction
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def reverse(x: int) -> int:
|
||||||
|
INT_MAX = 2**31 - 1 # 2147483647
|
||||||
|
INT_MIN = -2**31 # -2147483648
|
||||||
|
|
||||||
|
result = 0
|
||||||
|
# Track sign and work with absolute value
|
||||||
|
sign = 1 if x >= 0 else -1
|
||||||
|
x = abs(x)
|
||||||
|
|
||||||
|
while x != 0:
|
||||||
|
# Extract the last digit
|
||||||
|
digit = x % 10
|
||||||
|
x //= 10
|
||||||
|
|
||||||
|
# Check for overflow before multiplying
|
||||||
|
# If result > INT_MAX // 10, then result * 10 will overflow
|
||||||
|
# If result == INT_MAX // 10 and digit > 7, result * 10 + digit overflows
|
||||||
|
if result > INT_MAX // 10 or (result == INT_MAX // 10 and digit > 7):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Build the reversed number
|
||||||
|
result = result * 10 + digit
|
||||||
|
|
||||||
|
return sign * result
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(log x) — We process each digit once, and the number of digits is O(log x).
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — Only a constant number of variables are used.
|
||||||
|
|
||||||
|
We extract digits from right to left using modulo and integer division, building the reversed number by multiplying by 10 and adding each digit. The key is checking for overflow *before* the multiplication to stay within 32-bit bounds.
|
||||||
|
|
||||||
|
- approach_name: String Conversion
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def reverse(x: int) -> int:
|
||||||
|
INT_MAX = 2**31 - 1
|
||||||
|
INT_MIN = -2**31
|
||||||
|
|
||||||
|
# Handle sign
|
||||||
|
sign = 1 if x >= 0 else -1
|
||||||
|
x = abs(x)
|
||||||
|
|
||||||
|
# Convert to string, reverse, convert back
|
||||||
|
reversed_str = str(x)[::-1]
|
||||||
|
result = sign * int(reversed_str)
|
||||||
|
|
||||||
|
# Check bounds
|
||||||
|
if result < INT_MIN or result > INT_MAX:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
return result
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(log x) — String operations are proportional to the number of digits.
|
||||||
|
|
||||||
|
**Space Complexity:** O(log x) — The string representation uses space proportional to the number of digits.
|
||||||
|
|
||||||
|
This approach converts the number to a string, reverses it, and converts back. While simpler to implement, it uses extra space and arguably violates the spirit of the "no 64-bit integers" constraint since Python integers are arbitrary precision. The mathematical approach is preferred for interviews.
|
||||||
216
backend/data/questions/reverse-linked-list-ii.yaml
Normal file
216
backend/data/questions/reverse-linked-list-ii.yaml
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
title: Reverse Linked List II
|
||||||
|
slug: reverse-linked-list-ii
|
||||||
|
difficulty: medium
|
||||||
|
leetcode_id: 92
|
||||||
|
leetcode_url: https://leetcode.com/problems/reverse-linked-list-ii/
|
||||||
|
categories:
|
||||||
|
- linked-lists
|
||||||
|
patterns:
|
||||||
|
- linkedlist-reversal
|
||||||
|
|
||||||
|
description: |
|
||||||
|
Given the `head` of a singly linked list and two integers `left` and `right` where `left <= right`, reverse the nodes of the list from position `left` to position `right`, and return *the reversed list*.
|
||||||
|
|
||||||
|
**Note:** Positions are 1-indexed.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- The number of nodes in the list is `n`
|
||||||
|
- `1 <= n <= 500`
|
||||||
|
- `-500 <= Node.val <= 500`
|
||||||
|
- `1 <= left <= right <= n`
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: "head = [1,2,3,4,5], left = 2, right = 4"
|
||||||
|
output: "[1,4,3,2,5]"
|
||||||
|
explanation: "Nodes at positions 2, 3, and 4 (values 2, 3, 4) are reversed to become 4, 3, 2. The list becomes 1→4→3→2→5."
|
||||||
|
- input: "head = [5], left = 1, right = 1"
|
||||||
|
output: "[5]"
|
||||||
|
explanation: "A single node reversed is just itself."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Imagine you have a chain of paper clips where you want to reverse only a *section* in the middle, leaving the rest unchanged.
|
||||||
|
|
||||||
|
The key insight is to break the problem into parts:
|
||||||
|
1. **Navigate** to the node just before the reversal starts (position `left - 1`)
|
||||||
|
2. **Reverse** the sublist from position `left` to `right` (using the same technique from "Reverse Linked List")
|
||||||
|
3. **Reconnect** the reversed section back into the original list
|
||||||
|
|
||||||
|
Think of it like this: if you have `1 → 2 → 3 → 4 → 5` and want to reverse positions 2-4, you're essentially:
|
||||||
|
- Disconnecting the segment `2 → 3 → 4`
|
||||||
|
- Reversing it to `4 → 3 → 2`
|
||||||
|
- Reconnecting: node `1` now points to `4`, and node `2` now points to `5`
|
||||||
|
|
||||||
|
The tricky part is keeping track of the right nodes to reconnect. Using a **dummy node** before the head simplifies edge cases when `left = 1` (reversing from the beginning).
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using a **Single Pass with Dummy Node** approach:
|
||||||
|
|
||||||
|
**Step 1: Create a dummy node**
|
||||||
|
|
||||||
|
- `dummy.next = head`: This handles the edge case where `left = 1` gracefully
|
||||||
|
- `prev = dummy`: Start `prev` at the dummy so after `left - 1` moves, it's right before the reversal zone
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Move prev to the node before position left**
|
||||||
|
|
||||||
|
- Move `prev` forward `left - 1` times
|
||||||
|
- After this, `prev` points to the node just before where reversal begins
|
||||||
|
- `curr = prev.next`: This is the first node to be reversed (will become the tail of the reversed section)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Reverse the sublist using pointer manipulation**
|
||||||
|
|
||||||
|
- We perform `right - left` reversal operations
|
||||||
|
- For each operation:
|
||||||
|
- `next_node = curr.next`: The node we're moving to the front
|
||||||
|
- `curr.next = next_node.next`: Skip over `next_node`
|
||||||
|
- `next_node.next = prev.next`: Insert `next_node` at the front of the reversed section
|
||||||
|
- `prev.next = next_node`: Update `prev` to point to the new front
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 4: Return the new head**
|
||||||
|
|
||||||
|
- Return `dummy.next` (the dummy node handles the case where the head itself was reversed)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
This elegant approach reverses the section in-place without needing to first disconnect it.
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Not Using a Dummy Node
|
||||||
|
description: |
|
||||||
|
When `left = 1`, the head of the list changes. Without a dummy node, you need special-case handling:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Without dummy: messy edge case handling
|
||||||
|
if left == 1:
|
||||||
|
# Special logic for when head changes
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
A dummy node before the head means `prev` always has a valid node to work with, and `dummy.next` always points to the (possibly new) head.
|
||||||
|
wrong_approach: "Special-casing when left = 1"
|
||||||
|
correct_approach: "Use dummy node so prev.next always works"
|
||||||
|
|
||||||
|
- title: Off-by-One Errors in Counting
|
||||||
|
description: |
|
||||||
|
The problem uses **1-indexed** positions, but we iterate starting from index 0 in code.
|
||||||
|
|
||||||
|
- To reach the node *before* position `left`, move `prev` forward `left - 1` times
|
||||||
|
- To reverse nodes from `left` to `right`, perform `right - left` reversal operations
|
||||||
|
|
||||||
|
Trace through a small example: for `left = 2, right = 4`, we need to reverse 3 nodes, which requires `4 - 2 = 2` "move to front" operations.
|
||||||
|
wrong_approach: "Moving left times or reversing right - left + 1 times"
|
||||||
|
correct_approach: "Move left - 1 times, reverse right - left times"
|
||||||
|
|
||||||
|
- title: Losing Track of the Tail of Reversed Section
|
||||||
|
description: |
|
||||||
|
The node at position `left` (stored in `curr`) becomes the **tail** of the reversed section after all operations.
|
||||||
|
|
||||||
|
A common mistake is moving `curr` during the reversal. But `curr` should stay fixed — it's the anchor that will eventually connect to the node after position `right`.
|
||||||
|
|
||||||
|
The reversal works by repeatedly taking `curr.next` and moving it to the front, while `curr` stays in place (but its `next` pointer keeps changing).
|
||||||
|
wrong_approach: "Moving curr during reversal operations"
|
||||||
|
correct_approach: "Keep curr fixed; it moves nodes in front of itself"
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Dummy node pattern**: When the head might change, a dummy node before head simplifies the logic and eliminates edge cases"
|
||||||
|
- "**In-place reversal**: We don't need extra space for a temporary list — pointer manipulation reverses the section in-place"
|
||||||
|
- "**Building on fundamentals**: This problem combines 'navigate to position' with 'reverse linked list' — mastering the basic reversal makes this approachable"
|
||||||
|
- "**Follow-up achieved**: The single-pass approach processes each node at most once, achieving O(n) time"
|
||||||
|
|
||||||
|
time_complexity: "O(n). We traverse at most n nodes: `left - 1` to reach the start, then `right - left` reversal operations, totalling at most n steps."
|
||||||
|
space_complexity: "O(1). We only use a constant number of pointer variables (`dummy`, `prev`, `curr`, `next_node`), regardless of list size."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Single Pass with Dummy Node
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
class ListNode:
|
||||||
|
def __init__(self, val=0, next=None):
|
||||||
|
self.val = val
|
||||||
|
self.next = next
|
||||||
|
|
||||||
|
def reverse_between(head: ListNode | None, left: int, right: int) -> ListNode | None:
|
||||||
|
if not head or left == right:
|
||||||
|
return head # Nothing to reverse
|
||||||
|
|
||||||
|
# Dummy node handles edge case when left = 1
|
||||||
|
dummy = ListNode(0, head)
|
||||||
|
prev = dummy
|
||||||
|
|
||||||
|
# Move prev to the node just before position 'left'
|
||||||
|
for _ in range(left - 1):
|
||||||
|
prev = prev.next
|
||||||
|
|
||||||
|
# curr is the first node in the reversal zone
|
||||||
|
# It will become the tail of the reversed section
|
||||||
|
curr = prev.next
|
||||||
|
|
||||||
|
# Reverse by moving nodes one by one to the front
|
||||||
|
for _ in range(right - left):
|
||||||
|
# next_node is the node we're moving to the front
|
||||||
|
next_node = curr.next
|
||||||
|
|
||||||
|
# Remove next_node from its current position
|
||||||
|
curr.next = next_node.next
|
||||||
|
|
||||||
|
# Insert next_node at the front of the reversed section
|
||||||
|
next_node.next = prev.next
|
||||||
|
prev.next = next_node
|
||||||
|
|
||||||
|
return dummy.next
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — Single pass through the list.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — Only pointer variables used.
|
||||||
|
|
||||||
|
The key insight is that we don't physically disconnect and reconnect the sublist. Instead, we repeatedly take the node after `curr` and move it to the front of the reversed section. After `right - left` such operations, the sublist is reversed in place.
|
||||||
|
|
||||||
|
- approach_name: Two Pass (Extract and Reverse)
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def reverse_between(head: ListNode | None, left: int, right: int) -> ListNode | None:
|
||||||
|
if not head or left == right:
|
||||||
|
return head
|
||||||
|
|
||||||
|
dummy = ListNode(0, head)
|
||||||
|
|
||||||
|
# First pass: find the boundaries
|
||||||
|
before_left = dummy
|
||||||
|
for _ in range(left - 1):
|
||||||
|
before_left = before_left.next
|
||||||
|
|
||||||
|
# Mark the start and end of reversal section
|
||||||
|
rev_start = before_left.next
|
||||||
|
|
||||||
|
rev_end = before_left
|
||||||
|
for _ in range(right - left + 1):
|
||||||
|
rev_end = rev_end.next
|
||||||
|
|
||||||
|
after_right = rev_end.next
|
||||||
|
|
||||||
|
# Reverse the section using standard reversal
|
||||||
|
prev = after_right # New tail points to node after section
|
||||||
|
curr = rev_start
|
||||||
|
|
||||||
|
while curr != after_right:
|
||||||
|
next_node = curr.next
|
||||||
|
curr.next = prev
|
||||||
|
prev = curr
|
||||||
|
curr = next_node
|
||||||
|
|
||||||
|
# Reconnect: before_left now points to rev_end (new front)
|
||||||
|
before_left.next = prev
|
||||||
|
|
||||||
|
return dummy.next
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — Two passes through part of the list.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — Only pointer variables used.
|
||||||
|
|
||||||
|
This approach is more intuitive: first locate the boundaries, then apply the standard linked list reversal to that section. While still O(n), it requires more traversal than the single-pass approach. Useful for understanding, but the single-pass solution is more elegant.
|
||||||
174
backend/data/questions/reverse-linked-list.yaml
Normal file
174
backend/data/questions/reverse-linked-list.yaml
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
title: Reverse Linked List
|
||||||
|
slug: reverse-linked-list
|
||||||
|
difficulty: easy
|
||||||
|
leetcode_id: 206
|
||||||
|
leetcode_url: https://leetcode.com/problems/reverse-linked-list/
|
||||||
|
categories:
|
||||||
|
- linked-lists
|
||||||
|
- recursion
|
||||||
|
patterns:
|
||||||
|
- linkedlist-reversal
|
||||||
|
|
||||||
|
description: |
|
||||||
|
Given the `head` of a singly linked list, reverse the list, and return *the reversed list*.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- The number of nodes in the list is in the range `[0, 5000]`
|
||||||
|
- `-5000 <= Node.val <= 5000`
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: "head = [1,2,3,4,5]"
|
||||||
|
output: "[5,4,3,2,1]"
|
||||||
|
explanation: "The list 1→2→3→4→5 becomes 5→4→3→2→1."
|
||||||
|
- input: "head = [1,2]"
|
||||||
|
output: "[2,1]"
|
||||||
|
explanation: "The list 1→2 becomes 2→1."
|
||||||
|
- input: "head = []"
|
||||||
|
output: "[]"
|
||||||
|
explanation: "An empty list remains empty when reversed."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Imagine a chain of paper clips linked together, each pointing to the next. To reverse the chain, you need to make each clip point to the *previous* one instead of the next one.
|
||||||
|
|
||||||
|
Think of it like this: for each node, we're changing the direction of its arrow. Originally `A → B → C`, we want `A ← B ← C`. The challenge is that when we change a node's `next` pointer to point backwards, we lose our reference to move forward!
|
||||||
|
|
||||||
|
The solution is to use **three pointers**:
|
||||||
|
- `prev`: The node we're pointing back to
|
||||||
|
- `curr`: The node we're currently processing
|
||||||
|
- `next_node`: Saved reference to the next node (so we don't lose it when we reverse the link)
|
||||||
|
|
||||||
|
After processing all nodes, the original tail becomes the new head. Since `curr` ends up as `None` (past the last node), `prev` points to the last processed node — our new head.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using an **Iterative Three-Pointer Approach**:
|
||||||
|
|
||||||
|
**Step 1: Initialise pointers**
|
||||||
|
|
||||||
|
- `prev = None`: The new tail will point to `None`
|
||||||
|
- `curr = head`: Start at the beginning of the list
|
||||||
|
- We don't initialise `next_node` yet — we'll set it inside the loop
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Iterate through the list**
|
||||||
|
|
||||||
|
- While `curr` is not `None`:
|
||||||
|
- **Save the next node**: `next_node = curr.next` (before we lose it!)
|
||||||
|
- **Reverse the link**: `curr.next = prev` (point backwards)
|
||||||
|
- **Move prev forward**: `prev = curr`
|
||||||
|
- **Move curr forward**: `curr = next_node`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Return the new head**
|
||||||
|
|
||||||
|
- When the loop ends, `curr` is `None` (we've passed the last node)
|
||||||
|
- `prev` points to what was the last node — now the first node
|
||||||
|
- Return `prev` as the new head
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
This processes each node exactly once with constant extra space.
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Losing the Reference to the Next Node
|
||||||
|
description: |
|
||||||
|
If you reverse the link *before* saving the next node, you can't continue traversing!
|
||||||
|
|
||||||
|
```python
|
||||||
|
# WRONG: We lose access to the rest of the list
|
||||||
|
curr.next = prev
|
||||||
|
curr = curr.next # This now points backwards!
|
||||||
|
```
|
||||||
|
|
||||||
|
Always save `next_node = curr.next` **before** modifying `curr.next`.
|
||||||
|
wrong_approach: "curr.next = prev; curr = curr.next"
|
||||||
|
correct_approach: "next_node = curr.next; curr.next = prev; curr = next_node"
|
||||||
|
|
||||||
|
- title: Returning curr Instead of prev
|
||||||
|
description: |
|
||||||
|
After the loop, `curr` is `None` — we've moved past the end of the list. The actual new head is `prev`, which points to the last node we processed.
|
||||||
|
|
||||||
|
Think about it: in the final iteration, `curr` points to the last node, we process it, then `curr = next_node` makes `curr = None`.
|
||||||
|
wrong_approach: "return curr"
|
||||||
|
correct_approach: "return prev"
|
||||||
|
|
||||||
|
- title: Not Handling Empty or Single-Node Lists
|
||||||
|
description: |
|
||||||
|
The algorithm handles these edge cases naturally:
|
||||||
|
- **Empty list**: `head = None`, loop never executes, return `prev = None`
|
||||||
|
- **Single node**: Loop runs once, `prev` becomes the single node, which is returned
|
||||||
|
|
||||||
|
No special-case code is needed, but it's good to trace through these cases to verify.
|
||||||
|
wrong_approach: "Adding unnecessary if-checks for edge cases"
|
||||||
|
correct_approach: "Trust the algorithm — it handles empty and single-node lists"
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Fundamental operation**: Reversing a linked list is used in many problems (palindrome check, reverse in groups, etc.)"
|
||||||
|
- "**Three-pointer technique**: `prev`, `curr`, `next` is a common pattern for in-place linked list manipulation"
|
||||||
|
- "**Save before modifying**: When changing pointers, always save references you'll need later"
|
||||||
|
- "**Both iterative and recursive work**: Iterative is O(1) space, recursive is O(n) but more elegant — know both!"
|
||||||
|
|
||||||
|
time_complexity: "O(n). We visit each of the n nodes exactly once, performing O(1) work at each."
|
||||||
|
space_complexity: "O(1). We only use three pointer variables, regardless of list length."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Iterative
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
class ListNode:
|
||||||
|
def __init__(self, val=0, next=None):
|
||||||
|
self.val = val
|
||||||
|
self.next = next
|
||||||
|
|
||||||
|
def reverse_list(head: ListNode | None) -> ListNode | None:
|
||||||
|
prev = None # Will become the new tail
|
||||||
|
curr = head # Start at the head
|
||||||
|
|
||||||
|
while curr:
|
||||||
|
# Save next node before we overwrite the link
|
||||||
|
next_node = curr.next
|
||||||
|
|
||||||
|
# Reverse the link: point backwards
|
||||||
|
curr.next = prev
|
||||||
|
|
||||||
|
# Move pointers forward
|
||||||
|
prev = curr
|
||||||
|
curr = next_node
|
||||||
|
|
||||||
|
# prev is now the new head (curr is None)
|
||||||
|
return prev
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — Single pass through all nodes.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — Only three pointers used.
|
||||||
|
|
||||||
|
We iterate through the list once, reversing each link as we go. The key is saving the next node before modifying `curr.next`, then advancing both `prev` and `curr` forward.
|
||||||
|
|
||||||
|
- approach_name: Recursive
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def reverse_list(head: ListNode | None) -> ListNode | None:
|
||||||
|
# Base case: empty list or single node
|
||||||
|
if not head or not head.next:
|
||||||
|
return head
|
||||||
|
|
||||||
|
# Recursively reverse the rest of the list
|
||||||
|
new_head = reverse_list(head.next)
|
||||||
|
|
||||||
|
# head.next is now the tail of the reversed sublist
|
||||||
|
# Make it point back to head
|
||||||
|
head.next.next = head
|
||||||
|
|
||||||
|
# head is now the tail, so point it to None
|
||||||
|
head.next = None
|
||||||
|
|
||||||
|
# Return the new head (unchanged through recursion)
|
||||||
|
return new_head
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — Each node processed once.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n) — Recursion stack depth equals list length.
|
||||||
|
|
||||||
|
The recursive approach reverses the rest of the list first, then fixes the link for the current node. `head.next.next = head` makes the next node point back to us, and `head.next = None` makes us the new tail. The new head is propagated back through all recursive calls.
|
||||||
149
backend/data/questions/reverse-string.yaml
Normal file
149
backend/data/questions/reverse-string.yaml
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
title: Reverse String
|
||||||
|
slug: reverse-string
|
||||||
|
difficulty: easy
|
||||||
|
leetcode_id: 344
|
||||||
|
leetcode_url: https://leetcode.com/problems/reverse-string/
|
||||||
|
categories:
|
||||||
|
- strings
|
||||||
|
- two-pointers
|
||||||
|
patterns:
|
||||||
|
- two-pointers
|
||||||
|
|
||||||
|
description: |
|
||||||
|
Write a function that reverses a string. The input string is given as an array of characters `s`.
|
||||||
|
|
||||||
|
You must do this by modifying the input array *in-place* with `O(1)` extra memory.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `1 <= s.length <= 10^5`
|
||||||
|
- `s[i]` is a printable ASCII character
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: 's = ["h","e","l","l","o"]'
|
||||||
|
output: '["o","l","l","e","h"]'
|
||||||
|
explanation: "The array is reversed in place."
|
||||||
|
- input: 's = ["H","a","n","n","a","h"]'
|
||||||
|
output: '["h","a","n","n","a","H"]'
|
||||||
|
explanation: "The array is reversed in place. Notice how the case of each character is preserved."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Imagine holding a deck of cards face up in your hands. To reverse the order, you could pick up the top card and the bottom card, swap them, then move inward and repeat. Eventually, your hands meet in the middle and the deck is reversed.
|
||||||
|
|
||||||
|
This is exactly the **two-pointer technique**: place one pointer at the start and one at the end, swap the elements they point to, then move both pointers toward the center. When the pointers meet or cross, you're done.
|
||||||
|
|
||||||
|
The key insight is that reversing is just a series of **symmetric swaps**. The first element swaps with the last, the second with the second-to-last, and so on. Each swap is independent, so we only need `O(1)` extra space for the temporary variable during each swap.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using the **Two-Pointer Approach**:
|
||||||
|
|
||||||
|
**Step 1: Initialise two pointers**
|
||||||
|
|
||||||
|
- `left`: Set to `0`, pointing to the first element
|
||||||
|
- `right`: Set to `len(s) - 1`, pointing to the last element
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Swap and move inward**
|
||||||
|
|
||||||
|
- While `left < right`:
|
||||||
|
- Swap `s[left]` and `s[right]`
|
||||||
|
- Increment `left` by 1
|
||||||
|
- Decrement `right` by 1
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Termination**
|
||||||
|
|
||||||
|
- The loop exits when `left >= right`, meaning all swaps are complete
|
||||||
|
- The array is now reversed in place
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
This approach works because each swap puts two elements in their final positions. After `n/2` swaps (where `n` is the array length), every element has been moved to its mirror position.
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Creating a New Array
|
||||||
|
description: |
|
||||||
|
A common mistake is to create a new reversed array and copy it back:
|
||||||
|
|
||||||
|
```python
|
||||||
|
s[:] = s[::-1] # Creates a copy, uses O(n) space
|
||||||
|
```
|
||||||
|
|
||||||
|
While this works functionally, it violates the `O(1)` space constraint. The problem explicitly requires in-place modification without extra memory proportional to the input size.
|
||||||
|
wrong_approach: "Creating a reversed copy"
|
||||||
|
correct_approach: "Swap elements in place with two pointers"
|
||||||
|
|
||||||
|
- title: Off-by-One Errors
|
||||||
|
description: |
|
||||||
|
When setting up the pointers or the loop condition, it's easy to make off-by-one mistakes:
|
||||||
|
|
||||||
|
- Using `right = len(s)` instead of `right = len(s) - 1` causes an index out of bounds error
|
||||||
|
- Using `left <= right` instead of `left < right` causes an unnecessary swap when pointers meet at the middle element (harmless but wasteful)
|
||||||
|
|
||||||
|
Always trace through a small example like `["a", "b", "c"]` to verify your indices.
|
||||||
|
wrong_approach: "Incorrect index initialization or loop bounds"
|
||||||
|
correct_approach: "Carefully set right = len(s) - 1 and use left < right"
|
||||||
|
|
||||||
|
- title: Forgetting Python's Swap Syntax
|
||||||
|
description: |
|
||||||
|
In Python, you can swap two variables without a temporary variable using tuple unpacking:
|
||||||
|
|
||||||
|
```python
|
||||||
|
s[left], s[right] = s[right], s[left]
|
||||||
|
```
|
||||||
|
|
||||||
|
Some developers new to Python write more verbose swap logic with a temp variable, which works but is unnecessary. Both approaches use `O(1)` space.
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Two-pointer pattern**: When operating on both ends of a linear structure, two pointers moving inward is often the key"
|
||||||
|
- "**In-place modification**: Swapping elements directly avoids extra memory allocation"
|
||||||
|
- "**Symmetric operations**: Reversing is a series of independent swaps, making it parallelizable and simple to implement"
|
||||||
|
- "**Foundation for harder problems**: This technique extends to problems like reversing words in a string, palindrome checking, and rotating arrays"
|
||||||
|
|
||||||
|
time_complexity: "O(n). We perform `n/2` swaps, visiting each element exactly once."
|
||||||
|
space_complexity: "O(1). We only use two pointer variables regardless of input size."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Two Pointers
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def reverse_string(s: list[str]) -> None:
|
||||||
|
# Start pointers at both ends
|
||||||
|
left, right = 0, len(s) - 1
|
||||||
|
|
||||||
|
# Swap until pointers meet in the middle
|
||||||
|
while left < right:
|
||||||
|
# Swap the characters at left and right positions
|
||||||
|
s[left], s[right] = s[right], s[left]
|
||||||
|
# Move pointers toward the center
|
||||||
|
left += 1
|
||||||
|
right -= 1
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — We iterate through half the array, performing one swap per iteration.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — Only two integer variables are used.
|
||||||
|
|
||||||
|
The two-pointer technique efficiently reverses the array in a single pass from both ends. Each swap places two elements in their final positions, completing the reversal in `n/2` iterations.
|
||||||
|
|
||||||
|
- approach_name: Recursion
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def reverse_string(s: list[str]) -> None:
|
||||||
|
def helper(left: int, right: int) -> None:
|
||||||
|
# Base case: pointers have met or crossed
|
||||||
|
if left >= right:
|
||||||
|
return
|
||||||
|
# Swap current pair
|
||||||
|
s[left], s[right] = s[right], s[left]
|
||||||
|
# Recurse on the inner portion
|
||||||
|
helper(left + 1, right - 1)
|
||||||
|
|
||||||
|
helper(0, len(s) - 1)
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — Same number of swaps as the iterative approach.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n) — The call stack grows to depth `n/2`, using O(n) space.
|
||||||
|
|
||||||
|
While this recursive solution demonstrates the same logic, it violates the `O(1)` space constraint due to the call stack. It's included to show how recursion maps to the iterative solution, but the iterative approach is preferred here.
|
||||||
248
backend/data/questions/roman-to-integer.yaml
Normal file
248
backend/data/questions/roman-to-integer.yaml
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
title: Roman to Integer
|
||||||
|
slug: roman-to-integer
|
||||||
|
difficulty: easy
|
||||||
|
leetcode_id: 13
|
||||||
|
leetcode_url: https://leetcode.com/problems/roman-to-integer/
|
||||||
|
categories:
|
||||||
|
- strings
|
||||||
|
- hash-tables
|
||||||
|
- math
|
||||||
|
patterns:
|
||||||
|
- greedy
|
||||||
|
|
||||||
|
function_signature: "def roman_to_int(s: str) -> int:"
|
||||||
|
|
||||||
|
test_cases:
|
||||||
|
visible:
|
||||||
|
- input: { s: "III" }
|
||||||
|
expected: 3
|
||||||
|
- input: { s: "LVIII" }
|
||||||
|
expected: 58
|
||||||
|
- input: { s: "MCMXCIV" }
|
||||||
|
expected: 1994
|
||||||
|
hidden:
|
||||||
|
- input: { s: "IV" }
|
||||||
|
expected: 4
|
||||||
|
- input: { s: "IX" }
|
||||||
|
expected: 9
|
||||||
|
- input: { s: "XL" }
|
||||||
|
expected: 40
|
||||||
|
- input: { s: "XC" }
|
||||||
|
expected: 90
|
||||||
|
- input: { s: "CD" }
|
||||||
|
expected: 400
|
||||||
|
- input: { s: "CM" }
|
||||||
|
expected: 900
|
||||||
|
|
||||||
|
description: |
|
||||||
|
Roman numerals are represented by seven different symbols: `I`, `V`, `X`, `L`, `C`, `D` and `M`.
|
||||||
|
|
||||||
|
| Symbol | Value |
|
||||||
|
|--------|-------|
|
||||||
|
| I | 1 |
|
||||||
|
| V | 5 |
|
||||||
|
| X | 10 |
|
||||||
|
| L | 50 |
|
||||||
|
| C | 100 |
|
||||||
|
| D | 500 |
|
||||||
|
| M | 1000 |
|
||||||
|
|
||||||
|
For example, `2` is written as `II` in Roman numeral, just two ones added together. `12` is written as `XII`, which is simply `X + II`. The number `27` is written as `XXVII`, which is `XX + V + II`.
|
||||||
|
|
||||||
|
Roman numerals are usually written largest to smallest from left to right. However, the numeral for four is not `IIII`. Instead, the number four is written as `IV`. Because the one is before the five we subtract it making four. The same principle applies to the number nine, which is written as `IX`. There are six instances where subtraction is used:
|
||||||
|
|
||||||
|
- `I` can be placed before `V` (5) and `X` (10) to make 4 and 9
|
||||||
|
- `X` can be placed before `L` (50) and `C` (100) to make 40 and 90
|
||||||
|
- `C` can be placed before `D` (500) and `M` (1000) to make 400 and 900
|
||||||
|
|
||||||
|
Given a roman numeral, convert it to an integer.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `1 <= s.length <= 15`
|
||||||
|
- `s` contains only the characters `('I', 'V', 'X', 'L', 'C', 'D', 'M')`
|
||||||
|
- It is **guaranteed** that `s` is a valid roman numeral in the range `[1, 3999]`
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: 's = "III"'
|
||||||
|
output: "3"
|
||||||
|
explanation: "III = 3."
|
||||||
|
- input: 's = "LVIII"'
|
||||||
|
output: "58"
|
||||||
|
explanation: "L = 50, V = 5, III = 3."
|
||||||
|
- input: 's = "MCMXCIV"'
|
||||||
|
output: "1994"
|
||||||
|
explanation: "M = 1000, CM = 900, XC = 90 and IV = 4."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Imagine reading a Roman numeral from left to right like reading a sentence. Each symbol has a value, and normally you'd just add them all up. But there's a twist: sometimes a smaller symbol appears *before* a larger one, signalling subtraction instead of addition.
|
||||||
|
|
||||||
|
Think of it like this: when you see `IV`, the `I` (1) comes before `V` (5). This is the Roman way of saying "one less than five" = 4. The same logic applies to `IX` (9), `XL` (40), `XC` (90), `CD` (400), and `CM` (900).
|
||||||
|
|
||||||
|
The **core insight** is simple: as you scan from left to right, if a symbol is smaller than the one that follows it, you *subtract* its value instead of adding it. Otherwise, you add it normally.
|
||||||
|
|
||||||
|
This works because valid Roman numerals never have more than one subtraction symbol in a row. When you see a smaller value followed by a larger one, it's always a subtraction case.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using a **Single Pass with Lookahead** approach:
|
||||||
|
|
||||||
|
**Step 1: Create a symbol-to-value mapping**
|
||||||
|
|
||||||
|
- Use a hash map to store the value of each Roman symbol
|
||||||
|
- This gives O(1) lookup time for each character
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Initialise the result**
|
||||||
|
|
||||||
|
- `total`: Set to `0` to accumulate our answer
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Iterate through the string**
|
||||||
|
|
||||||
|
- For each position `i`, get the current symbol's value
|
||||||
|
- **Lookahead check**: Compare with the next symbol's value (if it exists)
|
||||||
|
- If current value < next value: this is a subtraction case, so *subtract* current value from total
|
||||||
|
- Otherwise: *add* current value to total
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 4: Return the result**
|
||||||
|
|
||||||
|
- After processing all characters, `total` contains the final integer value
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
The greedy nature of this approach works because we make the correct add/subtract decision at each step based on local information (current vs next symbol).
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Overcomplicating with Special Cases
|
||||||
|
description: |
|
||||||
|
A common mistake is trying to handle all six subtraction cases (`IV`, `IX`, `XL`, `XC`, `CD`, `CM`) as special two-character patterns with separate logic.
|
||||||
|
|
||||||
|
This leads to complex code with many conditionals. The elegant solution recognises that **all subtraction cases share one property**: the first symbol is smaller than the second.
|
||||||
|
|
||||||
|
Instead of checking for specific pairs, simply compare adjacent values.
|
||||||
|
wrong_approach: "if-else chains for IV, IX, XL, XC, CD, CM"
|
||||||
|
correct_approach: "Compare current value with next value"
|
||||||
|
|
||||||
|
- title: Off-by-One Errors in Lookahead
|
||||||
|
description: |
|
||||||
|
When comparing the current symbol with the next one, be careful at the end of the string. Accessing `s[i+1]` when `i` is the last index causes an index out of bounds error.
|
||||||
|
|
||||||
|
Always check that `i + 1 < len(s)` before accessing the next character, or iterate up to `len(s) - 1` for comparisons.
|
||||||
|
wrong_approach: "Accessing s[i+1] without bounds checking"
|
||||||
|
correct_approach: "Check i + 1 < len(s) before lookahead"
|
||||||
|
|
||||||
|
- title: Processing from Right to Left Confusion
|
||||||
|
description: |
|
||||||
|
Some solutions iterate right-to-left, which also works but can be confusing. The logic inverts: if current > previous (to the right), subtract. This is mathematically equivalent but less intuitive.
|
||||||
|
|
||||||
|
Left-to-right with lookahead matches how we naturally read Roman numerals.
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Pattern recognition over enumeration**: Instead of listing all special cases, find the underlying rule (smaller before larger = subtract)"
|
||||||
|
- "**Hash maps for symbol lookups**: O(1) character-to-value mapping is cleaner than switch statements"
|
||||||
|
- "**Lookahead technique**: Comparing current element with the next one is a common string/array pattern"
|
||||||
|
- "**Foundation for Integer to Roman**: Understanding this conversion helps with the reverse problem (LeetCode 12)"
|
||||||
|
|
||||||
|
time_complexity: "O(n). We traverse the string exactly once, where `n` is the length of the input string."
|
||||||
|
space_complexity: "O(1). The hash map has a fixed size of 7 entries regardless of input size."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Single Pass with Lookahead
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def roman_to_int(s: str) -> int:
|
||||||
|
# Map each Roman symbol to its integer value
|
||||||
|
values = {
|
||||||
|
'I': 1,
|
||||||
|
'V': 5,
|
||||||
|
'X': 10,
|
||||||
|
'L': 50,
|
||||||
|
'C': 100,
|
||||||
|
'D': 500,
|
||||||
|
'M': 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
n = len(s)
|
||||||
|
|
||||||
|
for i in range(n):
|
||||||
|
# Get current symbol's value
|
||||||
|
current = values[s[i]]
|
||||||
|
|
||||||
|
# If there's a next symbol and it's larger, subtract current
|
||||||
|
if i + 1 < n and current < values[s[i + 1]]:
|
||||||
|
total -= current
|
||||||
|
else:
|
||||||
|
# Otherwise, add current value
|
||||||
|
total += current
|
||||||
|
|
||||||
|
return total
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — Single pass through the string.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — Fixed-size hash map with 7 entries.
|
||||||
|
|
||||||
|
We iterate through each character, using lookahead to determine whether to add or subtract. The subtraction rule naturally handles all six special cases without explicit enumeration.
|
||||||
|
|
||||||
|
- approach_name: Right-to-Left Traversal
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def roman_to_int(s: str) -> int:
|
||||||
|
values = {
|
||||||
|
'I': 1, 'V': 5, 'X': 10, 'L': 50,
|
||||||
|
'C': 100, 'D': 500, 'M': 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
prev = 0 # Track previous value (to our right)
|
||||||
|
|
||||||
|
# Process from right to left
|
||||||
|
for char in reversed(s):
|
||||||
|
current = values[char]
|
||||||
|
|
||||||
|
# If current is smaller than what's to the right, subtract
|
||||||
|
if current < prev:
|
||||||
|
total -= current
|
||||||
|
else:
|
||||||
|
total += current
|
||||||
|
|
||||||
|
prev = current # Update previous for next iteration
|
||||||
|
|
||||||
|
return total
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — Single pass through the string.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — Fixed-size hash map with 7 entries.
|
||||||
|
|
||||||
|
Processing right-to-left with a `prev` variable is mathematically equivalent. If the current value is smaller than what we've already processed (to the right), we subtract. This avoids the bounds check needed for lookahead.
|
||||||
|
|
||||||
|
- approach_name: Replace Subtraction Pairs
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def roman_to_int(s: str) -> int:
|
||||||
|
# Replace subtraction pairs with additive equivalents
|
||||||
|
replacements = [
|
||||||
|
('IV', 'IIII'), # 4 = 1+1+1+1
|
||||||
|
('IX', 'VIIII'), # 9 = 5+1+1+1+1
|
||||||
|
('XL', 'XXXX'), # 40 = 10+10+10+10
|
||||||
|
('XC', 'LXXXX'), # 90 = 50+10+10+10+10
|
||||||
|
('CD', 'CCCC'), # 400 = 100+100+100+100
|
||||||
|
('CM', 'DCCCC') # 900 = 500+100+100+100+100
|
||||||
|
]
|
||||||
|
|
||||||
|
for old, new in replacements:
|
||||||
|
s = s.replace(old, new)
|
||||||
|
|
||||||
|
# Now simply sum all values
|
||||||
|
values = {'I': 1, 'V': 5, 'X': 10, 'L': 50, 'C': 100, 'D': 500, 'M': 1000}
|
||||||
|
return sum(values[c] for c in s)
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — String replacement and summation are both linear.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n) — Creates new strings during replacement.
|
||||||
|
|
||||||
|
This approach transforms the string to remove subtraction cases, then sums all values. While correct, it uses extra space and is less elegant than the lookahead solution. Included to show an alternative way of thinking about the problem.
|
||||||
222
backend/data/questions/rotate-array.yaml
Normal file
222
backend/data/questions/rotate-array.yaml
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
title: Rotate Array
|
||||||
|
slug: rotate-array
|
||||||
|
difficulty: medium
|
||||||
|
leetcode_id: 189
|
||||||
|
leetcode_url: https://leetcode.com/problems/rotate-array/
|
||||||
|
categories:
|
||||||
|
- arrays
|
||||||
|
- two-pointers
|
||||||
|
- math
|
||||||
|
patterns:
|
||||||
|
- two-pointers
|
||||||
|
|
||||||
|
description: |
|
||||||
|
Given an integer array `nums`, rotate the array to the right by `k` steps, where `k` is non-negative.
|
||||||
|
|
||||||
|
**Note:** You must modify the array *in-place* with O(1) extra space.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `1 <= nums.length <= 10^5`
|
||||||
|
- `-2^31 <= nums[i] <= 2^31 - 1`
|
||||||
|
- `0 <= k <= 10^5`
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: "nums = [1,2,3,4,5,6,7], k = 3"
|
||||||
|
output: "[5,6,7,1,2,3,4]"
|
||||||
|
explanation: "Rotate 1 step to the right: [7,1,2,3,4,5,6]. Rotate 2 steps: [6,7,1,2,3,4,5]. Rotate 3 steps: [5,6,7,1,2,3,4]."
|
||||||
|
- input: "nums = [-1,-100,3,99], k = 2"
|
||||||
|
output: "[3,99,-1,-100]"
|
||||||
|
explanation: "Rotate 1 step to the right: [99,-1,-100,3]. Rotate 2 steps: [3,99,-1,-100]."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Imagine a deck of cards on a table. Rotating right by `k` means taking the last `k` cards and moving them to the front.
|
||||||
|
|
||||||
|
The key insight is that **rotation is really just rearranging two segments** of the array. After rotating right by `k`, the array becomes: `[last k elements] + [first n-k elements]`.
|
||||||
|
|
||||||
|
But how do we achieve this rearrangement *in-place* without extra space? Here's the elegant trick: **three reversals**.
|
||||||
|
|
||||||
|
Think of it like this: if you reverse the entire array, then reverse the first `k` elements, then reverse the remaining elements, you end up with the rotated result. It's like flipping a string of beads, then adjusting the two halves.
|
||||||
|
|
||||||
|
For `[1,2,3,4,5,6,7]` with `k=3`:
|
||||||
|
- Reverse all: `[7,6,5,4,3,2,1]`
|
||||||
|
- Reverse first 3: `[5,6,7,4,3,2,1]`
|
||||||
|
- Reverse last 4: `[5,6,7,1,2,3,4]`
|
||||||
|
|
||||||
|
This works because reversing is like "flipping" segments, and the right sequence of flips puts everything in the correct rotated position.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using the **Reverse Three Times** approach:
|
||||||
|
|
||||||
|
**Step 1: Handle edge cases**
|
||||||
|
|
||||||
|
- If `k` is larger than the array length, rotating by `n` steps returns the array to its original position
|
||||||
|
- Use `k = k % n` to get the effective rotation amount
|
||||||
|
- If `k` becomes `0`, no rotation needed
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Reverse the entire array**
|
||||||
|
|
||||||
|
- Reverse all elements from index `0` to `n-1`
|
||||||
|
- This puts the last `k` elements at the front, but in reverse order
|
||||||
|
- The first `n-k` elements are now at the back, also in reverse order
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Reverse the first k elements**
|
||||||
|
|
||||||
|
- Reverse elements from index `0` to `k-1`
|
||||||
|
- This corrects the order of what will be the first part of the result
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 4: Reverse the remaining elements**
|
||||||
|
|
||||||
|
- Reverse elements from index `k` to `n-1`
|
||||||
|
- This corrects the order of the second part of the result
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Why it works:** Each element ends up in its correct rotated position after the three reversals. The reverse operation is in-place (using two pointers swapping from ends toward the middle), so we use O(1) extra space.
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Forgetting to Handle k > n
|
||||||
|
description: |
|
||||||
|
If `k` is larger than the array length, you might go out of bounds or produce incorrect results.
|
||||||
|
|
||||||
|
For example, with `nums = [1,2]` and `k = 3`, rotating right by 3 is the same as rotating by 1 (since rotating by 2 returns to original).
|
||||||
|
|
||||||
|
Always use `k = k % n` at the start to normalize `k` to a value within bounds.
|
||||||
|
wrong_approach: "Using k directly without modulo"
|
||||||
|
correct_approach: "Normalize with k = k % n before processing"
|
||||||
|
|
||||||
|
- title: Using Extra Space with Slicing
|
||||||
|
description: |
|
||||||
|
A tempting solution is: `nums[:] = nums[-k:] + nums[:-k]`
|
||||||
|
|
||||||
|
While this produces the correct result, it creates a new array to hold the concatenated slices, using **O(n) extra space**. The problem explicitly asks for O(1) space.
|
||||||
|
|
||||||
|
The reverse approach modifies the array in-place using only a few pointer variables.
|
||||||
|
wrong_approach: "Array slicing and concatenation"
|
||||||
|
correct_approach: "In-place reversal using two pointers"
|
||||||
|
|
||||||
|
- title: One-by-One Rotation
|
||||||
|
description: |
|
||||||
|
Another intuitive approach is to rotate by 1 step, `k` times:
|
||||||
|
- Store last element
|
||||||
|
- Shift all elements right by 1
|
||||||
|
- Place stored element at front
|
||||||
|
- Repeat `k` times
|
||||||
|
|
||||||
|
This is correct but has **O(n * k) time complexity**. With `n = k = 10^5`, that's 10 billion operations — a guaranteed TLE.
|
||||||
|
|
||||||
|
The triple-reverse approach is O(n) regardless of `k`.
|
||||||
|
wrong_approach: "Rotate one step at a time, k times"
|
||||||
|
correct_approach: "Triple reverse for O(n) time"
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Reversal trick**: Three reversals can achieve rotation in O(n) time and O(1) space — a classic technique"
|
||||||
|
- "**Modulo for cycles**: When dealing with rotations or circular operations, `k % n` normalizes the operation"
|
||||||
|
- "**In-place operations**: Two-pointer swapping from both ends is the standard way to reverse in-place"
|
||||||
|
- "**Pattern recognition**: This same reversal technique works for rotating strings and other sequence problems"
|
||||||
|
|
||||||
|
time_complexity: "O(n). Each element is visited at most twice (once per reversal it participates in)."
|
||||||
|
space_complexity: "O(1). We only use a few variables for indices and swapping, regardless of input size."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Reverse Three Times
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def rotate(nums: list[int], k: int) -> None:
|
||||||
|
"""
|
||||||
|
Rotate array in-place using triple reversal.
|
||||||
|
"""
|
||||||
|
n = len(nums)
|
||||||
|
# Normalize k to handle cases where k > n
|
||||||
|
k = k % n
|
||||||
|
|
||||||
|
# Helper function to reverse a portion of the array
|
||||||
|
def reverse(left: int, right: int) -> None:
|
||||||
|
while left < right:
|
||||||
|
nums[left], nums[right] = nums[right], nums[left]
|
||||||
|
left += 1
|
||||||
|
right -= 1
|
||||||
|
|
||||||
|
# Step 1: Reverse the entire array
|
||||||
|
reverse(0, n - 1)
|
||||||
|
# Step 2: Reverse the first k elements
|
||||||
|
reverse(0, k - 1)
|
||||||
|
# Step 3: Reverse the remaining elements
|
||||||
|
reverse(k, n - 1)
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — Each element is moved at most twice.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — Only a few variables for indices.
|
||||||
|
|
||||||
|
The triple-reverse technique elegantly achieves rotation without extra arrays. By reversing the whole array, then reversing two subarrays, each element lands in its correct rotated position.
|
||||||
|
|
||||||
|
- approach_name: Cyclic Replacements
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def rotate(nums: list[int], k: int) -> None:
|
||||||
|
"""
|
||||||
|
Rotate using cyclic replacements - each element jumps to its final position.
|
||||||
|
"""
|
||||||
|
n = len(nums)
|
||||||
|
k = k % n
|
||||||
|
if k == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
count = 0 # Track how many elements we've moved
|
||||||
|
start = 0
|
||||||
|
|
||||||
|
while count < n:
|
||||||
|
current = start
|
||||||
|
prev = nums[start]
|
||||||
|
|
||||||
|
# Follow the cycle until we return to start
|
||||||
|
while True:
|
||||||
|
next_idx = (current + k) % n
|
||||||
|
# Swap: place prev at next_idx, save what was there
|
||||||
|
nums[next_idx], prev = prev, nums[next_idx]
|
||||||
|
current = next_idx
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
# If we've returned to start, this cycle is complete
|
||||||
|
if current == start:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Move to next starting position for next cycle
|
||||||
|
start += 1
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — Each element is moved exactly once.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — Only tracking indices and one temp value.
|
||||||
|
|
||||||
|
This approach directly places each element at its final position by following cycles. We start at index 0, move the element to its destination `(0 + k) % n`, then move whatever was there to its destination, and so on until we return to the start. If we haven't moved all elements, we start a new cycle from the next index.
|
||||||
|
|
||||||
|
- approach_name: Extra Array (Not Optimal for Space)
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def rotate(nums: list[int], k: int) -> None:
|
||||||
|
"""
|
||||||
|
Rotate using an extra array - simple but uses O(n) space.
|
||||||
|
"""
|
||||||
|
n = len(nums)
|
||||||
|
k = k % n
|
||||||
|
|
||||||
|
# Create a rotated copy
|
||||||
|
rotated = [0] * n
|
||||||
|
for i in range(n):
|
||||||
|
rotated[(i + k) % n] = nums[i]
|
||||||
|
|
||||||
|
# Copy back to original array
|
||||||
|
for i in range(n):
|
||||||
|
nums[i] = rotated[i]
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n) — Two passes through the array.
|
||||||
|
|
||||||
|
**Space Complexity:** O(n) — Extra array to store rotated result.
|
||||||
|
|
||||||
|
This straightforward approach creates a new array where each element is placed directly at its rotated position. While correct and easy to understand, it uses O(n) extra space, which doesn't meet the follow-up challenge of O(1) space. Useful for understanding the problem before optimizing.
|
||||||
166
backend/data/questions/rotate-image.yaml
Normal file
166
backend/data/questions/rotate-image.yaml
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
title: Rotate Image
|
||||||
|
slug: rotate-image
|
||||||
|
difficulty: medium
|
||||||
|
leetcode_id: 48
|
||||||
|
leetcode_url: https://leetcode.com/problems/rotate-image/
|
||||||
|
categories:
|
||||||
|
- arrays
|
||||||
|
- math
|
||||||
|
patterns:
|
||||||
|
- matrix-manipulation
|
||||||
|
|
||||||
|
description: |
|
||||||
|
You are given an `n × n` 2D matrix representing an image. Rotate the image by **90 degrees** (clockwise).
|
||||||
|
|
||||||
|
You have to rotate the image **in-place**, which means you have to modify the input 2D matrix directly. **DO NOT** allocate another 2D matrix and do the rotation.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `n == matrix.length == matrix[i].length`
|
||||||
|
- `1 <= n <= 20`
|
||||||
|
- `-1000 <= matrix[i][j] <= 1000`
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: "matrix = [[1,2,3],[4,5,6],[7,8,9]]"
|
||||||
|
output: "[[7,4,1],[8,5,2],[9,6,3]]"
|
||||||
|
explanation: "The 3×3 matrix is rotated 90 degrees clockwise."
|
||||||
|
- input: "matrix = [[5,1,9,11],[2,4,8,10],[13,3,6,7],[15,14,12,16]]"
|
||||||
|
output: "[[15,13,2,5],[14,3,4,1],[12,6,8,9],[16,7,10,11]]"
|
||||||
|
explanation: "The 4×4 matrix is rotated 90 degrees clockwise."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Rotating a matrix 90° clockwise might seem like it requires complex index manipulation, but there's a beautiful mathematical insight: the rotation can be decomposed into **two simple operations**.
|
||||||
|
|
||||||
|
Think of it like this: a 90° clockwise rotation moves element at position `(i, j)` to position `(j, n-1-i)`. This transformation happens to be equivalent to:
|
||||||
|
1. **Transpose** the matrix (swap rows and columns)
|
||||||
|
2. **Reverse** each row
|
||||||
|
|
||||||
|
Visually for a 3×3 matrix:
|
||||||
|
```
|
||||||
|
Original Transpose Reverse rows
|
||||||
|
1 2 3 1 4 7 7 4 1
|
||||||
|
4 5 6 → 2 5 8 → 8 5 2
|
||||||
|
7 8 9 3 6 9 9 6 3
|
||||||
|
```
|
||||||
|
|
||||||
|
Both operations can be done in-place with simple swaps, making this the cleanest approach.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using **Transpose + Reverse**:
|
||||||
|
|
||||||
|
**Step 1: Transpose the matrix**
|
||||||
|
|
||||||
|
- Swap `matrix[i][j]` with `matrix[j][i]` for all `i < j`
|
||||||
|
- Only iterate over the upper triangle (`j > i`) to avoid undoing swaps
|
||||||
|
- After this, rows become columns and vice versa
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Reverse each row**
|
||||||
|
|
||||||
|
- For each row, reverse the order of elements
|
||||||
|
- Python's `row.reverse()` does this in-place
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Why does this work?**
|
||||||
|
|
||||||
|
- Transpose: `(i, j) → (j, i)`
|
||||||
|
- Reverse row: `(j, i) → (j, n-1-i)`
|
||||||
|
- Combined: `(i, j) → (j, n-1-i)` — exactly 90° clockwise rotation!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
This decomposition turns a complex problem into two simple, understandable operations.
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Transposing the Entire Matrix Twice
|
||||||
|
description: |
|
||||||
|
When transposing, only swap elements in the **upper triangle** (where `j > i`). If you iterate over all pairs, you'll swap `(i,j)` and then `(j,i)` — undoing the first swap!
|
||||||
|
|
||||||
|
The inner loop should start at `j = i + 1`, not `j = 0`.
|
||||||
|
wrong_approach: "for j in range(n): swap (i,j) and (j,i)"
|
||||||
|
correct_approach: "for j in range(i + 1, n): swap (i,j) and (j,i)"
|
||||||
|
|
||||||
|
- title: Confusing Clockwise and Counterclockwise
|
||||||
|
description: |
|
||||||
|
- **Clockwise 90°**: Transpose, then reverse each row
|
||||||
|
- **Counterclockwise 90°**: Transpose, then reverse each column (or reverse rows first, then transpose)
|
||||||
|
|
||||||
|
Getting this wrong rotates in the opposite direction.
|
||||||
|
wrong_approach: "Reversing columns for clockwise rotation"
|
||||||
|
correct_approach: "Transpose + reverse rows = clockwise"
|
||||||
|
|
||||||
|
- title: Allocating Extra Space
|
||||||
|
description: |
|
||||||
|
The problem explicitly requires in-place rotation. Both transpose and row reversal can be done with swaps using O(1) extra space.
|
||||||
|
|
||||||
|
Creating a new matrix and copying values violates the constraint.
|
||||||
|
wrong_approach: "Creating a new n×n matrix for the result"
|
||||||
|
correct_approach: "Use in-place swaps only"
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Decompose complex transformations**: 90° rotation = transpose + reverse"
|
||||||
|
- "**Upper triangle only for transpose**: Avoid undoing swaps by only iterating where `j > i`"
|
||||||
|
- "**Know your rotations**: Clockwise vs counterclockwise requires different orderings"
|
||||||
|
- "**In-place operations are possible**: Most matrix transformations can be done with careful swapping"
|
||||||
|
|
||||||
|
time_complexity: "O(n²). We visit each element a constant number of times during transpose and reversal."
|
||||||
|
space_complexity: "O(1). All operations are done in-place using only swaps."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Transpose + Reverse
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
def rotate(matrix: list[list[int]]) -> None:
|
||||||
|
n = len(matrix)
|
||||||
|
|
||||||
|
# Step 1: Transpose (swap across main diagonal)
|
||||||
|
# Only iterate upper triangle to avoid undoing swaps
|
||||||
|
for i in range(n):
|
||||||
|
for j in range(i + 1, n):
|
||||||
|
matrix[i][j], matrix[j][i] = matrix[j][i], matrix[i][j]
|
||||||
|
|
||||||
|
# Step 2: Reverse each row
|
||||||
|
for row in matrix:
|
||||||
|
row.reverse()
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n²) — Visit each element constant times.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — In-place swaps only.
|
||||||
|
|
||||||
|
Transpose swaps elements across the main diagonal, converting rows to columns. Reversing each row then completes the 90° clockwise rotation. Both operations are simple and in-place.
|
||||||
|
|
||||||
|
- approach_name: Layer-by-Layer Rotation
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def rotate(matrix: list[list[int]]) -> None:
|
||||||
|
n = len(matrix)
|
||||||
|
|
||||||
|
# Rotate layer by layer from outside to inside
|
||||||
|
for layer in range(n // 2):
|
||||||
|
first, last = layer, n - 1 - layer
|
||||||
|
|
||||||
|
for i in range(first, last):
|
||||||
|
offset = i - first
|
||||||
|
|
||||||
|
# Save top element
|
||||||
|
top = matrix[first][i]
|
||||||
|
|
||||||
|
# Move left → top
|
||||||
|
matrix[first][i] = matrix[last - offset][first]
|
||||||
|
|
||||||
|
# Move bottom → left
|
||||||
|
matrix[last - offset][first] = matrix[last][last - offset]
|
||||||
|
|
||||||
|
# Move right → bottom
|
||||||
|
matrix[last][last - offset] = matrix[i][last]
|
||||||
|
|
||||||
|
# Move top → right (using saved value)
|
||||||
|
matrix[i][last] = top
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(n²) — Visit each element once.
|
||||||
|
|
||||||
|
**Space Complexity:** O(1) — Only one temp variable.
|
||||||
|
|
||||||
|
Rotate the matrix layer by layer, from the outermost ring inward. For each position in a layer, perform a four-way swap moving elements clockwise. Correct but more complex to implement and understand than transpose+reverse.
|
||||||
220
backend/data/questions/rotting-oranges.yaml
Normal file
220
backend/data/questions/rotting-oranges.yaml
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
title: Rotting Oranges
|
||||||
|
slug: rotting-oranges
|
||||||
|
difficulty: medium
|
||||||
|
leetcode_id: 994
|
||||||
|
leetcode_url: https://leetcode.com/problems/rotting-oranges/
|
||||||
|
categories:
|
||||||
|
- arrays
|
||||||
|
- graphs
|
||||||
|
patterns:
|
||||||
|
- bfs
|
||||||
|
- matrix-traversal
|
||||||
|
|
||||||
|
description: |
|
||||||
|
You are given an `m x n` `grid` where each cell can have one of three values:
|
||||||
|
|
||||||
|
- `0` representing an empty cell,
|
||||||
|
- `1` representing a fresh orange, or
|
||||||
|
- `2` representing a rotten orange.
|
||||||
|
|
||||||
|
Every minute, any fresh orange that is **4-directionally adjacent** to a rotten orange becomes rotten.
|
||||||
|
|
||||||
|
Return *the minimum number of minutes that must elapse until no cell has a fresh orange*. If *this is impossible, return* `-1`.
|
||||||
|
|
||||||
|
constraints: |
|
||||||
|
- `m == grid.length`
|
||||||
|
- `n == grid[i].length`
|
||||||
|
- `1 <= m, n <= 10`
|
||||||
|
- `grid[i][j]` is `0`, `1`, or `2`
|
||||||
|
|
||||||
|
examples:
|
||||||
|
- input: "grid = [[2,1,1],[1,1,0],[0,1,1]]"
|
||||||
|
output: "4"
|
||||||
|
explanation: "Starting from the rotten orange at position (0,0), adjacent oranges rot each minute. After 4 minutes, all reachable fresh oranges become rotten."
|
||||||
|
- input: "grid = [[2,1,1],[0,1,1],[1,0,1]]"
|
||||||
|
output: "-1"
|
||||||
|
explanation: "The orange in the bottom left corner (row 2, column 0) is never rotten, because rotting only happens 4-directionally and it's isolated by the empty cell."
|
||||||
|
- input: "grid = [[0,2]]"
|
||||||
|
output: "0"
|
||||||
|
explanation: "Since there are already no fresh oranges at minute 0, the answer is just 0."
|
||||||
|
|
||||||
|
explanation:
|
||||||
|
intuition: |
|
||||||
|
Imagine the rotting process spreading like ripples in a pond. Each rotten orange is a stone dropped in the water, and the "rot" spreads outward one cell at a time in all four directions (up, down, left, right).
|
||||||
|
|
||||||
|
The key insight is that **all rotten oranges spread simultaneously**. This isn't a single-source problem where rot spreads from one orange — it's a **multi-source BFS** where all initially rotten oranges act as starting points at the same time.
|
||||||
|
|
||||||
|
Think of it like this: at minute 0, all initially rotten oranges "infect" their adjacent fresh oranges. At minute 1, all those newly rotten oranges infect *their* adjacent fresh oranges. This level-by-level expansion is exactly what BFS does naturally.
|
||||||
|
|
||||||
|
The answer is the number of "levels" or "waves" of BFS until no fresh oranges remain. If any fresh orange is unreachable (isolated by empty cells or grid boundaries), we return `-1`.
|
||||||
|
|
||||||
|
approach: |
|
||||||
|
We solve this using a **Multi-Source BFS** approach:
|
||||||
|
|
||||||
|
**Step 1: Initialise the queue and count fresh oranges**
|
||||||
|
|
||||||
|
- Scan the entire grid once
|
||||||
|
- Add all rotten oranges (value `2`) to a queue — these are our starting points
|
||||||
|
- Count all fresh oranges (value `1`) — we'll decrement this as oranges rot
|
||||||
|
- `fresh_count`: Tracks remaining fresh oranges
|
||||||
|
- `minutes`: Tracks elapsed time (starts at `0`)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 2: Perform BFS level by level**
|
||||||
|
|
||||||
|
- While the queue is not empty and fresh oranges remain:
|
||||||
|
- Record the current queue size — this is the number of oranges rotting at this "level"
|
||||||
|
- Process all oranges at the current level
|
||||||
|
- For each rotten orange, check all 4 adjacent cells (up, down, left, right)
|
||||||
|
- If an adjacent cell contains a fresh orange, rot it (change to `2`), add to queue, decrement `fresh_count`
|
||||||
|
- After processing the entire level, increment `minutes`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Step 3: Check for unreachable oranges**
|
||||||
|
|
||||||
|
- If `fresh_count > 0` after BFS completes, some oranges were unreachable — return `-1`
|
||||||
|
- Otherwise, return `minutes`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
The BFS guarantees we find the minimum time because we explore all oranges at distance `d` before any orange at distance `d+1`.
|
||||||
|
|
||||||
|
common_pitfalls:
|
||||||
|
- title: Single-Source Instead of Multi-Source BFS
|
||||||
|
description: |
|
||||||
|
A common mistake is to run BFS from each rotten orange separately and take the maximum. This is both inefficient and incorrect.
|
||||||
|
|
||||||
|
The correct approach is to add **all** initially rotten oranges to the queue before starting BFS. This simulates the simultaneous spread from all sources.
|
||||||
|
|
||||||
|
With multi-source BFS, we process all "distance-1" oranges before any "distance-2" oranges, naturally giving us the minimum time.
|
||||||
|
wrong_approach: "Run separate BFS from each rotten orange"
|
||||||
|
correct_approach: "Add all rotten oranges to queue initially (multi-source BFS)"
|
||||||
|
|
||||||
|
- title: Forgetting Edge Cases
|
||||||
|
description: |
|
||||||
|
Don't forget to handle these scenarios:
|
||||||
|
|
||||||
|
- **No fresh oranges**: Return `0` immediately — nothing needs to rot
|
||||||
|
- **No rotten oranges but fresh oranges exist**: Return `-1` — rot can never spread
|
||||||
|
- **Isolated fresh oranges**: Fresh oranges surrounded by empty cells or walls can never rot
|
||||||
|
|
||||||
|
The `fresh_count` check at the end handles all unreachable cases.
|
||||||
|
|
||||||
|
- title: Off-by-One in Time Counting
|
||||||
|
description: |
|
||||||
|
Be careful when counting minutes. The BFS starts at minute `0` with the initial rotten oranges. Each "wave" of new rotting takes one minute.
|
||||||
|
|
||||||
|
Only increment `minutes` when you actually rot new oranges. If you increment before checking if any rotting happened, you'll get an incorrect answer when the grid starts with no fresh oranges.
|
||||||
|
|
||||||
|
key_takeaways:
|
||||||
|
- "**Multi-source BFS**: When spreading from multiple starting points simultaneously, initialise the queue with all sources before starting BFS"
|
||||||
|
- "**Level-order processing**: Process BFS level-by-level to track the number of steps/waves/minutes"
|
||||||
|
- "**Grid traversal pattern**: The 4-directional adjacency `[(0,1), (0,-1), (1,0), (-1,0)]` is a common pattern for matrix problems"
|
||||||
|
- "**Reachability check**: Track unvisited targets to detect impossible cases — BFS naturally handles this"
|
||||||
|
|
||||||
|
time_complexity: "O(m × n). We visit each cell at most twice — once during initial scan, once during BFS."
|
||||||
|
space_complexity: "O(m × n). In the worst case, all cells are rotten and stored in the queue."
|
||||||
|
|
||||||
|
solutions:
|
||||||
|
- approach_name: Multi-Source BFS
|
||||||
|
is_optimal: true
|
||||||
|
code: |
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
def oranges_rotting(grid: list[list[int]]) -> int:
|
||||||
|
rows, cols = len(grid), len(grid[0])
|
||||||
|
queue = deque()
|
||||||
|
fresh_count = 0
|
||||||
|
|
||||||
|
# Step 1: Find all rotten oranges and count fresh ones
|
||||||
|
for r in range(rows):
|
||||||
|
for c in range(cols):
|
||||||
|
if grid[r][c] == 2:
|
||||||
|
queue.append((r, c)) # Add rotten orange to queue
|
||||||
|
elif grid[r][c] == 1:
|
||||||
|
fresh_count += 1 # Count fresh oranges
|
||||||
|
|
||||||
|
# Edge case: no fresh oranges to begin with
|
||||||
|
if fresh_count == 0:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Directions for 4-directional movement
|
||||||
|
directions = [(0, 1), (0, -1), (1, 0), (-1, 0)]
|
||||||
|
minutes = 0
|
||||||
|
|
||||||
|
# Step 2: BFS level by level
|
||||||
|
while queue and fresh_count > 0:
|
||||||
|
minutes += 1 # Each level = 1 minute
|
||||||
|
# Process all oranges at current level
|
||||||
|
for _ in range(len(queue)):
|
||||||
|
r, c = queue.popleft()
|
||||||
|
# Check all 4 adjacent cells
|
||||||
|
for dr, dc in directions:
|
||||||
|
nr, nc = r + dr, c + dc
|
||||||
|
# If valid cell with fresh orange, rot it
|
||||||
|
if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == 1:
|
||||||
|
grid[nr][nc] = 2 # Mark as rotten
|
||||||
|
fresh_count -= 1
|
||||||
|
queue.append((nr, nc))
|
||||||
|
|
||||||
|
# Step 3: Check if all oranges rotted
|
||||||
|
return minutes if fresh_count == 0 else -1
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O(m × n) — Each cell is visited at most once during BFS.
|
||||||
|
|
||||||
|
**Space Complexity:** O(m × n) — Queue can hold all cells in the worst case.
|
||||||
|
|
||||||
|
We use multi-source BFS where all initially rotten oranges spread simultaneously. By processing level-by-level, we naturally count the minimum minutes needed. The `fresh_count` tracker lets us detect unreachable oranges.
|
||||||
|
|
||||||
|
- approach_name: Simulation (Brute Force)
|
||||||
|
is_optimal: false
|
||||||
|
code: |
|
||||||
|
def oranges_rotting(grid: list[list[int]]) -> int:
|
||||||
|
rows, cols = len(grid), len(grid[0])
|
||||||
|
directions = [(0, 1), (0, -1), (1, 0), (-1, 0)]
|
||||||
|
minutes = 0
|
||||||
|
|
||||||
|
def count_fresh():
|
||||||
|
"""Count remaining fresh oranges."""
|
||||||
|
count = 0
|
||||||
|
for r in range(rows):
|
||||||
|
for c in range(cols):
|
||||||
|
if grid[r][c] == 1:
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
# Simulate until no changes occur
|
||||||
|
while True:
|
||||||
|
fresh_before = count_fresh()
|
||||||
|
if fresh_before == 0:
|
||||||
|
return minutes
|
||||||
|
|
||||||
|
# Find all oranges that will rot this minute
|
||||||
|
to_rot = []
|
||||||
|
for r in range(rows):
|
||||||
|
for c in range(cols):
|
||||||
|
if grid[r][c] == 1: # Fresh orange
|
||||||
|
# Check if adjacent to rotten
|
||||||
|
for dr, dc in directions:
|
||||||
|
nr, nc = r + dr, c + dc
|
||||||
|
if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == 2:
|
||||||
|
to_rot.append((r, c))
|
||||||
|
break
|
||||||
|
|
||||||
|
# If nothing can rot, but fresh remain, impossible
|
||||||
|
if not to_rot:
|
||||||
|
return -1
|
||||||
|
|
||||||
|
# Rot the oranges
|
||||||
|
for r, c in to_rot:
|
||||||
|
grid[r][c] = 2
|
||||||
|
|
||||||
|
minutes += 1
|
||||||
|
explanation: |
|
||||||
|
**Time Complexity:** O((m × n)²) — Each minute we scan the entire grid, and there can be O(m × n) minutes.
|
||||||
|
|
||||||
|
**Space Complexity:** O(m × n) — We store oranges to rot each round.
|
||||||
|
|
||||||
|
This approach simulates the process directly: each minute, scan the grid, find fresh oranges adjacent to rotten ones, and rot them. While correct, it's inefficient because we repeatedly scan cells that won't change. The BFS approach is preferred.
|
||||||
Reference in New Issue
Block a user