questions C
This commit is contained in:
289
backend/data/questions/cache-with-time-limit.yaml
Normal file
289
backend/data/questions/cache-with-time-limit.yaml
Normal file
@@ -0,0 +1,289 @@
|
||||
title: Cache With Time Limit
|
||||
slug: cache-with-time-limit
|
||||
difficulty: medium
|
||||
leetcode_id: 2622
|
||||
leetcode_url: https://leetcode.com/problems/cache-with-time-limit/
|
||||
categories:
|
||||
- hash-tables
|
||||
patterns:
|
||||
- heap
|
||||
|
||||
description: |
|
||||
Write a class that allows getting and setting key-value pairs, however a **time until expiration** is associated with each key.
|
||||
|
||||
The class has three public methods:
|
||||
|
||||
`set(key, value, duration)`: accepts an integer `key`, an integer `value`, and a `duration` in milliseconds. Once the `duration` has elapsed, the key should be inaccessible. The method should return `true` if the same un-expired key already exists and `false` otherwise. Both the value and duration should be overwritten if the key already exists.
|
||||
|
||||
`get(key)`: if an un-expired key exists, it should return the associated value. Otherwise it should return `-1`.
|
||||
|
||||
`count()`: returns the count of un-expired keys.
|
||||
|
||||
constraints: |
|
||||
- `0 <= key, value <= 10^9`
|
||||
- `0 <= duration <= 1000`
|
||||
- `1 <= actions.length <= 100`
|
||||
- `actions.length === values.length`
|
||||
- `actions.length === timeDelays.length`
|
||||
- `0 <= timeDelays[i] <= 1450`
|
||||
- `actions[i]` is one of `"TimeLimitedCache"`, `"set"`, `"get"` and `"count"`
|
||||
- First action is always `"TimeLimitedCache"` and must be executed immediately, with a 0-millisecond delay
|
||||
|
||||
examples:
|
||||
- input: |
|
||||
actions = ["TimeLimitedCache", "set", "get", "count", "get"]
|
||||
values = [[], [1, 42, 100], [1], [], [1]]
|
||||
timeDelays = [0, 0, 50, 50, 150]
|
||||
output: "[null, false, 42, 1, -1]"
|
||||
explanation: |
|
||||
At t=0, the cache is constructed.
|
||||
At t=0, a key-value pair (1: 42) is added with a time limit of 100ms. The value doesn't exist so false is returned.
|
||||
At t=50, key=1 is requested and the value of 42 is returned.
|
||||
At t=50, count() is called and there is one active key in the cache.
|
||||
At t=100, key=1 expires.
|
||||
At t=150, get(1) is called but -1 is returned because the cache is empty.
|
||||
- input: |
|
||||
actions = ["TimeLimitedCache", "set", "set", "get", "get", "get", "count"]
|
||||
values = [[], [1, 42, 50], [1, 50, 100], [1], [1], [1], []]
|
||||
timeDelays = [0, 0, 40, 50, 120, 200, 250]
|
||||
output: "[null, false, true, 50, 50, -1, 0]"
|
||||
explanation: |
|
||||
At t=0, the cache is constructed.
|
||||
At t=0, a key-value pair (1: 42) is added with a time limit of 50ms. The value doesn't exist so false is returned.
|
||||
At t=40, a key-value pair (1: 50) is added with a time limit of 100ms. A non-expired value already existed so true is returned and the old value was overwritten.
|
||||
At t=50, get(1) is called which returned 50.
|
||||
At t=120, get(1) is called which returned 50.
|
||||
At t=140, key=1 expires.
|
||||
At t=200, get(1) is called but the cache is empty so -1 is returned.
|
||||
At t=250, count() returns 0 because the cache is empty.
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Imagine a parking garage where each car gets a ticket with an expiration time. When you enter, you're given a ticket that's valid for a certain duration. If you try to use an expired ticket, it's worthless — the system won't recognise it anymore.
|
||||
|
||||
The core insight for this problem is that we need to associate **two pieces of information** with each key: the value itself, and *when* it expires. Rather than storing the duration, we store the **absolute expiration timestamp** (current time + duration). This makes checking validity trivial — we just compare the current time against the stored expiration.
|
||||
|
||||
Think of it like this: when you set a key, you're placing an item on a shelf with a "best before" date stamped on it. When someone asks for that item (`get`), you check if today's date is past the expiration — if so, the item is spoiled and you return `-1`. When counting items, you only count those that haven't expired yet.
|
||||
|
||||
The elegant part is that we don't need to actively remove expired entries at the moment they expire. We can use **lazy deletion** — only check expiration when someone actually tries to access the key. This simplifies the implementation significantly.
|
||||
|
||||
approach: |
|
||||
We solve this using a **Hash Map with Expiration Timestamps**:
|
||||
|
||||
**Step 1: Design the data structure**
|
||||
|
||||
- Use a `Map` (or object) to store key-value pairs
|
||||
- For each key, store both the `value` and the `expirationTime` (calculated as `Date.now() + duration`)
|
||||
- Optionally store the `timeoutId` so we can cancel old timers when overwriting
|
||||
|
||||
|
||||
|
||||
**Step 2: Implement `set(key, value, duration)`**
|
||||
|
||||
- Check if the key already exists AND hasn't expired
|
||||
- If an unexpired key exists, clear its old timeout and return `true`
|
||||
- Calculate the new expiration time: `Date.now() + duration`
|
||||
- Store the value and expiration time in the map
|
||||
- Set a `setTimeout` to automatically delete the key after `duration` milliseconds
|
||||
- Return `false` if the key didn't exist or was expired
|
||||
|
||||
|
||||
|
||||
**Step 3: Implement `get(key)`**
|
||||
|
||||
- Check if the key exists in the map
|
||||
- If it exists and `Date.now() < expirationTime`, return the value
|
||||
- Otherwise, return `-1`
|
||||
|
||||
|
||||
|
||||
**Step 4: Implement `count()`**
|
||||
|
||||
- Iterate through all entries in the map
|
||||
- Count only those where `Date.now() < expirationTime`
|
||||
- Return the count
|
||||
|
||||
|
||||
|
||||
The `setTimeout` approach ensures keys are cleaned up eventually, while the expiration check provides correctness even if there are timing edge cases.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Storing Duration Instead of Expiration Time
|
||||
description: |
|
||||
A common mistake is storing the `duration` value directly and trying to compute expiration later.
|
||||
|
||||
The problem is: later *when*? You'd need to also store the timestamp when the key was set. It's simpler and less error-prone to compute and store the absolute expiration time upfront: `expirationTime = Date.now() + duration`.
|
||||
|
||||
This makes all subsequent checks trivial: `isExpired = Date.now() >= expirationTime`.
|
||||
wrong_approach: "Storing duration and set-time separately"
|
||||
correct_approach: "Store absolute expiration timestamp"
|
||||
|
||||
- title: Not Cancelling Old Timers on Overwrite
|
||||
description: |
|
||||
When `set` is called with an existing key, the old timer is still running. If you don't cancel it with `clearTimeout`, the old timer will fire and delete your new entry prematurely!
|
||||
|
||||
For example: `set(1, 42, 1000)` then `set(1, 99, 5000)`. If you don't cancel the first timer, it will delete key 1 after 1000ms, even though the new entry should last 5000ms.
|
||||
wrong_approach: "Ignoring the old timeout when overwriting"
|
||||
correct_approach: "Store timeoutId and call clearTimeout before overwriting"
|
||||
|
||||
- title: Race Conditions with Lazy vs Eager Deletion
|
||||
description: |
|
||||
There are two approaches to handling expiration:
|
||||
- **Eager deletion**: Use `setTimeout` to delete exactly when the key expires
|
||||
- **Lazy deletion**: Check expiration only when the key is accessed
|
||||
|
||||
Using *only* lazy deletion means expired keys still take up memory until accessed. Using *only* eager deletion can have edge cases with timer precision. The safest approach combines both: set a timeout for cleanup, but always verify expiration on access.
|
||||
wrong_approach: "Relying solely on one deletion strategy"
|
||||
correct_approach: "Combine setTimeout with expiration checks on access"
|
||||
|
||||
- title: Forgetting That `set` Returns Based on Unexpired Status
|
||||
description: |
|
||||
The `set` method returns `true` only if an **unexpired** key already exists. If the key exists but has expired, you should return `false` (treating it as a new key).
|
||||
|
||||
This is easy to miss if you simply check `map.has(key)` without also verifying the expiration status.
|
||||
wrong_approach: "Returning true if key exists in map (regardless of expiration)"
|
||||
correct_approach: "Return true only if key exists AND hasn't expired"
|
||||
|
||||
key_takeaways:
|
||||
- "**Absolute timestamps over durations**: Store `expirationTime = now + duration` rather than storing duration separately — makes comparisons trivial"
|
||||
- "**Combine eager and lazy deletion**: Use `setTimeout` for cleanup but always verify expiration on access for correctness"
|
||||
- "**Cancel old timers**: When overwriting a key, always clear the previous timeout to prevent premature deletion"
|
||||
- "**This pattern is foundational**: Time-limited caches are used everywhere — session tokens, rate limiters, memoization with TTL, and API response caching"
|
||||
|
||||
time_complexity: "O(1) for `set` and `get` operations using hash map lookup. O(n) for `count` where n is the number of entries in the cache (must check each for expiration)."
|
||||
space_complexity: "O(n) where n is the number of unexpired keys stored in the cache. Each entry stores the key, value, expiration time, and timeout reference."
|
||||
|
||||
solutions:
|
||||
- approach_name: Hash Map with setTimeout
|
||||
is_optimal: true
|
||||
language: typescript
|
||||
code: |
|
||||
class TimeLimitedCache {
|
||||
// Map stores: key -> { value, expirationTime, timeoutId }
|
||||
private cache: Map<number, {
|
||||
value: number;
|
||||
expirationTime: number;
|
||||
timeoutId: ReturnType<typeof setTimeout>;
|
||||
}>;
|
||||
|
||||
constructor() {
|
||||
this.cache = new Map();
|
||||
}
|
||||
|
||||
set(key: number, value: number, duration: number): boolean {
|
||||
// Check if unexpired key already exists
|
||||
const existing = this.cache.get(key);
|
||||
const keyExisted = existing !== undefined &&
|
||||
Date.now() < existing.expirationTime;
|
||||
|
||||
// Cancel old timer if key exists (expired or not)
|
||||
if (existing) {
|
||||
clearTimeout(existing.timeoutId);
|
||||
}
|
||||
|
||||
// Calculate absolute expiration time
|
||||
const expirationTime = Date.now() + duration;
|
||||
|
||||
// Set up automatic cleanup when key expires
|
||||
const timeoutId = setTimeout(() => {
|
||||
this.cache.delete(key);
|
||||
}, duration);
|
||||
|
||||
// Store the entry with all metadata
|
||||
this.cache.set(key, { value, expirationTime, timeoutId });
|
||||
|
||||
return keyExisted;
|
||||
}
|
||||
|
||||
get(key: number): number {
|
||||
const entry = this.cache.get(key);
|
||||
|
||||
// Key doesn't exist
|
||||
if (!entry) return -1;
|
||||
|
||||
// Key exists but has expired (lazy check for edge cases)
|
||||
if (Date.now() >= entry.expirationTime) return -1;
|
||||
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
count(): number {
|
||||
const now = Date.now();
|
||||
let activeCount = 0;
|
||||
|
||||
// Count only unexpired entries
|
||||
for (const entry of this.cache.values()) {
|
||||
if (now < entry.expirationTime) {
|
||||
activeCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return activeCount;
|
||||
}
|
||||
}
|
||||
explanation: |
|
||||
**Time Complexity:** O(1) for `set` and `get`, O(n) for `count`.
|
||||
|
||||
**Space Complexity:** O(n) for storing cache entries.
|
||||
|
||||
This solution uses a Map to store entries with their expiration timestamps and timeout IDs. The `setTimeout` ensures eventual cleanup, while the expiration check on `get` provides correctness. Storing the `timeoutId` allows us to cancel old timers when overwriting keys.
|
||||
|
||||
- approach_name: Simple Map with Lazy Deletion
|
||||
is_optimal: false
|
||||
language: typescript
|
||||
code: |
|
||||
class TimeLimitedCache {
|
||||
// Map stores: key -> { value, expirationTime }
|
||||
private cache: Map<number, { value: number; expirationTime: number }>;
|
||||
|
||||
constructor() {
|
||||
this.cache = new Map();
|
||||
}
|
||||
|
||||
set(key: number, value: number, duration: number): boolean {
|
||||
const now = Date.now();
|
||||
const existing = this.cache.get(key);
|
||||
|
||||
// Check if an unexpired key exists
|
||||
const keyExisted = existing !== undefined &&
|
||||
now < existing.expirationTime;
|
||||
|
||||
// Store with absolute expiration time
|
||||
this.cache.set(key, {
|
||||
value,
|
||||
expirationTime: now + duration
|
||||
});
|
||||
|
||||
return keyExisted;
|
||||
}
|
||||
|
||||
get(key: number): number {
|
||||
const entry = this.cache.get(key);
|
||||
|
||||
// No entry or expired
|
||||
if (!entry || Date.now() >= entry.expirationTime) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
count(): number {
|
||||
const now = Date.now();
|
||||
let count = 0;
|
||||
|
||||
for (const entry of this.cache.values()) {
|
||||
if (now < entry.expirationTime) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
}
|
||||
explanation: |
|
||||
**Time Complexity:** O(1) for `set` and `get`, O(n) for `count`.
|
||||
|
||||
**Space Complexity:** O(n) but expired entries persist until overwritten.
|
||||
|
||||
This simpler approach uses only lazy deletion — expired entries remain in the map until accessed or overwritten. It's correct but uses more memory since expired entries aren't cleaned up automatically. Suitable for scenarios with frequent overwrites or short-lived caches.
|
||||
173
backend/data/questions/calculate-amount-paid-in-taxes.yaml
Normal file
173
backend/data/questions/calculate-amount-paid-in-taxes.yaml
Normal file
@@ -0,0 +1,173 @@
|
||||
title: Calculate Amount Paid in Taxes
|
||||
slug: calculate-amount-paid-in-taxes
|
||||
difficulty: easy
|
||||
leetcode_id: 2303
|
||||
leetcode_url: https://leetcode.com/problems/calculate-amount-paid-in-taxes/
|
||||
categories:
|
||||
- arrays
|
||||
- math
|
||||
patterns:
|
||||
- greedy
|
||||
|
||||
description: |
|
||||
You are given a **0-indexed** 2D integer array `brackets` where `brackets[i] = [upper_i, percent_i]` means that the i<sup>th</sup> tax bracket has an upper bound of `upper_i` and is taxed at a rate of `percent_i`. The brackets are **sorted** by upper bound (i.e., `upper_{i-1} < upper_i` for `0 < i < brackets.length`).
|
||||
|
||||
Tax is calculated as follows:
|
||||
|
||||
- The first `upper_0` dollars earned are taxed at a rate of `percent_0`.
|
||||
- The next `upper_1 - upper_0` dollars earned are taxed at a rate of `percent_1`.
|
||||
- The next `upper_2 - upper_1` dollars earned are taxed at a rate of `percent_2`.
|
||||
- And so on.
|
||||
|
||||
You are given an integer `income` representing the amount of money you earned. Return *the amount of money that you have to pay in taxes*. Answers within `10^-5` of the actual answer will be accepted.
|
||||
|
||||
constraints: |
|
||||
- `1 <= brackets.length <= 100`
|
||||
- `1 <= upper_i <= 1000`
|
||||
- `0 <= percent_i <= 100`
|
||||
- `0 <= income <= 1000`
|
||||
- `upper_i` is sorted in ascending order
|
||||
- All the values of `upper_i` are **unique**
|
||||
- The upper bound of the last tax bracket is greater than or equal to `income`
|
||||
|
||||
examples:
|
||||
- input: "brackets = [[3,50],[7,10],[12,25]], income = 10"
|
||||
output: "2.65000"
|
||||
explanation: "Based on your income, you have 3 dollars in the 1st tax bracket, 4 dollars in the 2nd tax bracket, and 3 dollars in the 3rd tax bracket. The tax rate for the three tax brackets is 50%, 10%, and 25%, respectively. In total, you pay $3 × 50% + $4 × 10% + $3 × 25% = $2.65 in taxes."
|
||||
- input: "brackets = [[1,0],[4,25],[5,50]], income = 2"
|
||||
output: "0.25000"
|
||||
explanation: "Based on your income, you have 1 dollar in the 1st tax bracket and 1 dollar in the 2nd tax bracket. The tax rate for the two tax brackets is 0% and 25%, respectively. In total, you pay $1 × 0% + $1 × 25% = $0.25 in taxes."
|
||||
- input: "brackets = [[2,50]], income = 0"
|
||||
output: "0.00000"
|
||||
explanation: "You have no income to tax, so you have to pay a total of $0 in taxes."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Think of tax brackets like filling buckets of different sizes, where each bucket charges a different "fee" based on how much water you pour into it.
|
||||
|
||||
Imagine you have a series of buckets stacked on top of each other. The first bucket holds the first few dollars of income, the second bucket holds the next portion, and so on. Each bucket has its own tax rate — the "cost" of filling that bucket. Your income flows down through these buckets from top to bottom, and you pay the associated rate for however much fills each one.
|
||||
|
||||
The key insight is that brackets are **cumulative thresholds**, not individual bucket sizes. Each bracket's `upper` value tells you the total income covered *up to that point*. So the amount taxable at each rate is the difference between the current bracket's upper bound and the previous one's — that's the size of each "bucket."
|
||||
|
||||
Since we process income from the lowest bracket upward and stop once we've accounted for all income, this naturally follows a **greedy simulation**: handle each bracket in order, take as much income as fits, apply the rate, and move on.
|
||||
|
||||
approach: |
|
||||
We solve this using a **Linear Simulation** approach:
|
||||
|
||||
**Step 1: Initialise tracking variables**
|
||||
|
||||
- `total_tax`: Set to `0.0` to accumulate the tax owed
|
||||
- `previous_upper`: Set to `0` to track where the last bracket ended (first bracket starts from $0)
|
||||
|
||||
|
||||
|
||||
**Step 2: Iterate through each bracket**
|
||||
|
||||
- For each `[upper, percent]` pair in `brackets`:
|
||||
- Calculate the **taxable amount** in this bracket: `min(income, upper) - previous_upper`
|
||||
- If `taxable_amount > 0`, add the tax: `taxable_amount * percent / 100`
|
||||
- Update `previous_upper = upper` for the next iteration
|
||||
- If `income <= upper`, we've covered all income — we can stop early
|
||||
|
||||
|
||||
|
||||
**Step 3: Return the total tax**
|
||||
|
||||
- Return `total_tax` after processing all relevant brackets
|
||||
|
||||
|
||||
|
||||
This greedy approach works because brackets are sorted by upper bound, so we naturally process income from lowest to highest rates in the order they apply.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Confusing Upper Bounds with Bracket Sizes
|
||||
description: |
|
||||
A common mistake is treating `upper` as the size of the bracket rather than a cumulative threshold.
|
||||
|
||||
For example, with `brackets = [[3,50],[7,10]]`, the second bracket doesn't cover 7 dollars — it covers dollars 4 through 7 (i.e., 4 dollars). The taxable amount in bracket `i` is `upper_i - upper_{i-1}`, not `upper_i` itself.
|
||||
|
||||
Always subtract the previous bracket's upper bound to get the actual taxable amount in each bracket.
|
||||
wrong_approach: "Using upper directly as the taxable amount"
|
||||
correct_approach: "Calculate upper - previous_upper for each bracket's taxable portion"
|
||||
|
||||
- title: Not Capping at Income
|
||||
description: |
|
||||
If your income is less than a bracket's upper bound, you don't fill the entire bracket — only the portion up to your income.
|
||||
|
||||
For example, with `income = 5` and a bracket `[10, 20]`, you don't tax 10 dollars at 20%. If the previous bracket ended at 3, you only tax `5 - 3 = 2` dollars at 20%.
|
||||
|
||||
Use `min(income, upper) - previous_upper` to ensure you never tax more than you earned.
|
||||
wrong_approach: "Always using upper - previous_upper"
|
||||
correct_approach: "Using min(income, upper) - previous_upper"
|
||||
|
||||
- title: Forgetting to Convert Percentage
|
||||
description: |
|
||||
The `percent` values are given as integers (e.g., `50` for 50%), not decimals. Forgetting to divide by 100 will give answers 100× too large.
|
||||
|
||||
Always calculate tax as `amount * percent / 100`.
|
||||
|
||||
key_takeaways:
|
||||
- "**Bracket simulation pattern**: Process sorted intervals sequentially, tracking the boundary between processed and unprocessed portions"
|
||||
- "**Cumulative vs incremental**: When given cumulative bounds, compute incremental amounts by subtracting the previous bound"
|
||||
- "**Early termination**: Once income is fully covered, remaining brackets don't contribute — exit early for efficiency"
|
||||
- "**Real-world relevance**: This exact logic is used in actual tax systems worldwide (progressive taxation)"
|
||||
|
||||
time_complexity: "O(n). We iterate through the brackets array once, where `n` is the number of tax brackets."
|
||||
space_complexity: "O(1). We only use a constant number of variables (`total_tax`, `previous_upper`) regardless of input size."
|
||||
|
||||
solutions:
|
||||
- approach_name: Linear Simulation
|
||||
is_optimal: true
|
||||
code: |
|
||||
def calculate_tax(brackets: list[list[int]], income: int) -> float:
|
||||
total_tax = 0.0
|
||||
previous_upper = 0
|
||||
|
||||
for upper, percent in brackets:
|
||||
# Calculate how much income falls in this bracket
|
||||
# Cap at income if we don't fill the entire bracket
|
||||
taxable_amount = min(income, upper) - previous_upper
|
||||
|
||||
# Only add tax if there's income in this bracket
|
||||
if taxable_amount > 0:
|
||||
total_tax += taxable_amount * percent / 100
|
||||
|
||||
# Move the boundary for the next bracket
|
||||
previous_upper = upper
|
||||
|
||||
# Early exit: all income has been accounted for
|
||||
if income <= upper:
|
||||
break
|
||||
|
||||
return total_tax
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — We iterate through brackets at most once.
|
||||
|
||||
**Space Complexity:** O(1) — Only a few variables used.
|
||||
|
||||
We process each bracket in order, calculating how much of our income falls within that bracket's range. By tracking `previous_upper`, we know where each bracket starts. The `min(income, upper)` ensures we don't count income beyond what we actually earned. Early termination optimises for cases where income doesn't reach higher brackets.
|
||||
|
||||
- approach_name: Without Early Exit
|
||||
is_optimal: false
|
||||
code: |
|
||||
def calculate_tax(brackets: list[list[int]], income: int) -> float:
|
||||
total_tax = 0.0
|
||||
previous_upper = 0
|
||||
|
||||
for upper, percent in brackets:
|
||||
# Calculate taxable amount in this bracket
|
||||
taxable_amount = min(income, upper) - previous_upper
|
||||
|
||||
# Add tax if positive (handles case where income < previous_upper)
|
||||
if taxable_amount > 0:
|
||||
total_tax += taxable_amount * percent / 100
|
||||
|
||||
previous_upper = upper
|
||||
|
||||
return total_tax
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Always iterates through all brackets.
|
||||
|
||||
**Space Complexity:** O(1) — Only a few variables used.
|
||||
|
||||
This version always processes all brackets but still produces correct results because `taxable_amount` becomes 0 or negative once `income <= previous_upper`. The `if taxable_amount > 0` check prevents adding anything for brackets beyond our income. While functionally correct, the version with early exit is marginally more efficient.
|
||||
159
backend/data/questions/calculate-digit-sum-of-a-string.yaml
Normal file
159
backend/data/questions/calculate-digit-sum-of-a-string.yaml
Normal file
@@ -0,0 +1,159 @@
|
||||
title: Calculate Digit Sum of a String
|
||||
slug: calculate-digit-sum-of-a-string
|
||||
difficulty: easy
|
||||
leetcode_id: 2243
|
||||
leetcode_url: https://leetcode.com/problems/calculate-digit-sum-of-a-string/
|
||||
categories:
|
||||
- strings
|
||||
patterns:
|
||||
- sliding-window
|
||||
|
||||
description: |
|
||||
You are given a string `s` consisting of digits and an integer `k`.
|
||||
|
||||
A **round** can be completed if the length of `s` is greater than `k`. In one round, do the following:
|
||||
|
||||
1. **Divide** `s` into **consecutive groups** of size `k` such that the first `k` characters are in the first group, the next `k` characters are in the second group, and so on. **Note** that the size of the last group can be smaller than `k`.
|
||||
|
||||
2. **Replace** each group of `s` with a string representing the sum of all its digits. For example, `"346"` is replaced with `"13"` because `3 + 4 + 6 = 13`.
|
||||
|
||||
3. **Merge** consecutive groups together to form a new string. If the length of the string is greater than `k`, repeat from step 1.
|
||||
|
||||
Return `s` *after all rounds have been completed*.
|
||||
|
||||
constraints: |
|
||||
- `1 <= s.length <= 100`
|
||||
- `2 <= k <= 100`
|
||||
- `s` consists of digits only.
|
||||
|
||||
examples:
|
||||
- input: 's = "11111222223", k = 3'
|
||||
output: '"135"'
|
||||
explanation: "In the first round, we divide s into groups: \"111\", \"112\", \"222\", and \"23\". The digit sums are 3, 4, 6, and 5, giving \"3465\". In the second round, we divide into \"346\" and \"5\", with sums 13 and 5, giving \"135\". Since len(\"135\") <= 3, we return \"135\"."
|
||||
- input: 's = "00000000", k = 3'
|
||||
output: '"000"'
|
||||
explanation: "We divide s into \"000\", \"000\", and \"00\". Each group sums to 0, so we get \"000\". Since len(\"000\") equals k, we return \"000\"."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Imagine you have a very long receipt with a sequence of digits, and you want to compress it into a shorter summary. The rule is simple: split the digits into chunks of size `k`, add up the digits in each chunk, and write down those sums. If the result is still too long, repeat the process.
|
||||
|
||||
Think of it like repeatedly "squashing" the string. Each round takes groups of digits and compresses them into their digit sums. The string gets shorter with each round (in most cases), and eventually it becomes short enough — length `k` or less — to be the final answer.
|
||||
|
||||
The key insight is that this is a **simulation problem**: there's no clever shortcut. You simply follow the rules step by step until the termination condition is met. The constraint `s.length <= 100` is small enough that even a straightforward implementation runs quickly.
|
||||
|
||||
approach: |
|
||||
We solve this using a **Simulation Approach**:
|
||||
|
||||
**Step 1: Set up the loop condition**
|
||||
|
||||
- Continue processing while `len(s) > k`
|
||||
- Once the string is short enough, we're done
|
||||
|
||||
|
||||
|
||||
**Step 2: Divide the string into groups**
|
||||
|
||||
- Iterate through `s` in steps of `k`
|
||||
- Extract each chunk using slicing: `s[i:i+k]`
|
||||
- The last chunk may have fewer than `k` characters (that's allowed)
|
||||
|
||||
|
||||
|
||||
**Step 3: Calculate the digit sum for each group**
|
||||
|
||||
- For each chunk, sum the integer values of its characters
|
||||
- Convert each digit character to an integer with `int(char)`
|
||||
- Convert the sum back to a string
|
||||
|
||||
|
||||
|
||||
**Step 4: Merge and repeat**
|
||||
|
||||
- Concatenate all the digit sum strings to form the new `s`
|
||||
- Go back to step 1 and check if another round is needed
|
||||
|
||||
|
||||
|
||||
**Step 5: Return the result**
|
||||
|
||||
- When `len(s) <= k`, return `s` as the answer
|
||||
|
||||
common_pitfalls:
|
||||
- title: Off-by-One Errors in Chunking
|
||||
description: |
|
||||
When dividing the string into groups, it's easy to make mistakes with indices. Using `range(0, len(s), k)` with slicing `s[i:i+k]` handles this cleanly — Python's slicing automatically handles the case where `i+k` exceeds the string length.
|
||||
|
||||
A common mistake is trying to manually check if you're at the last group and handling it specially. This adds unnecessary complexity and risk.
|
||||
wrong_approach: "Manual index tracking with special case for last group"
|
||||
correct_approach: "Use range with step k and let Python slicing handle boundaries"
|
||||
|
||||
- title: Forgetting to Convert Types
|
||||
description: |
|
||||
The string contains digit *characters* (`'1'`, `'2'`, etc.), not integers. You must convert each character to an integer before summing, then convert the sum back to a string.
|
||||
|
||||
Forgetting `int()` will cause string concatenation instead of addition: `'1' + '2' + '3'` becomes `'123'`, not `6`.
|
||||
wrong_approach: "sum(chunk) without converting to integers"
|
||||
correct_approach: "sum(int(c) for c in chunk) then str() the result"
|
||||
|
||||
- title: Infinite Loop Risk
|
||||
description: |
|
||||
If you accidentally don't update `s` with the new compressed string, or have a bug in your loop condition, you could loop forever.
|
||||
|
||||
Always ensure `s` is reassigned to the new string at the end of each round, and verify your while condition uses the updated length.
|
||||
|
||||
key_takeaways:
|
||||
- "**Simulation pattern**: Some problems have no clever trick — just follow the rules carefully until termination"
|
||||
- "**Python slicing**: `s[i:i+k]` safely handles boundary cases where `i+k` exceeds string length"
|
||||
- "**Type awareness**: Remember to convert between strings and integers when working with digit strings"
|
||||
- "**Small constraints**: With `n <= 100`, even O(n²) approaches are acceptable, so focus on correctness over micro-optimisation"
|
||||
|
||||
time_complexity: "O(n) in practice. Each round reduces the string length significantly (by roughly a factor of k), so the total work across all rounds is linear in the original length."
|
||||
space_complexity: "O(n). We create new strings during each round, with the total space bounded by the input size."
|
||||
|
||||
solutions:
|
||||
- approach_name: Simulation
|
||||
is_optimal: true
|
||||
code: |
|
||||
def digit_sum(s: str, k: int) -> str:
|
||||
# Keep compressing until string is short enough
|
||||
while len(s) > k:
|
||||
new_s = ""
|
||||
# Process the string in chunks of size k
|
||||
for i in range(0, len(s), k):
|
||||
# Extract the current group (last group may be smaller)
|
||||
chunk = s[i:i + k]
|
||||
# Sum the digits and convert back to string
|
||||
chunk_sum = sum(int(c) for c in chunk)
|
||||
new_s += str(chunk_sum)
|
||||
# Update s for the next round
|
||||
s = new_s
|
||||
return s
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Each character is processed once per round, and the string shrinks significantly each round.
|
||||
|
||||
**Space Complexity:** O(n) — We build new strings during processing.
|
||||
|
||||
We simulate the process exactly as described: divide into groups of `k`, sum each group's digits, merge, and repeat until the length is at most `k`. Python's slicing makes the chunking straightforward.
|
||||
|
||||
- approach_name: Simulation with List Join
|
||||
is_optimal: true
|
||||
code: |
|
||||
def digit_sum(s: str, k: int) -> str:
|
||||
while len(s) > k:
|
||||
# Build list of digit sums for each chunk
|
||||
parts = []
|
||||
for i in range(0, len(s), k):
|
||||
chunk = s[i:i + k]
|
||||
# Sum digits in this chunk
|
||||
total = sum(int(c) for c in chunk)
|
||||
parts.append(str(total))
|
||||
# Join all parts to form new string
|
||||
s = "".join(parts)
|
||||
return s
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Same as the basic simulation.
|
||||
|
||||
**Space Complexity:** O(n) — We store parts in a list before joining.
|
||||
|
||||
This variant collects all chunk sums in a list and uses `"".join()` at the end of each round. In Python, this is often more efficient for building strings since it avoids creating intermediate string objects during concatenation.
|
||||
169
backend/data/questions/calculate-money-in-leetcode-bank.yaml
Normal file
169
backend/data/questions/calculate-money-in-leetcode-bank.yaml
Normal file
@@ -0,0 +1,169 @@
|
||||
title: Calculate Money in Leetcode Bank
|
||||
slug: calculate-money-in-leetcode-bank
|
||||
difficulty: easy
|
||||
leetcode_id: 1716
|
||||
leetcode_url: https://leetcode.com/problems/calculate-money-in-leetcode-bank/
|
||||
categories:
|
||||
- math
|
||||
patterns:
|
||||
- prefix-sum
|
||||
|
||||
description: |
|
||||
Hercy wants to save money for his first car. He puts money in the Leetcode bank **every day**.
|
||||
|
||||
He starts by putting in `$1` on Monday, the first day. Every day from Tuesday to Sunday, he will put in `$1` more than the day before. On every subsequent Monday, he will put in `$1` more than the **previous Monday**.
|
||||
|
||||
Given `n`, return *the total amount of money he will have in the Leetcode bank at the end of the* `n`<sup>th</sup> *day*.
|
||||
|
||||
constraints: |
|
||||
- `1 <= n <= 1000`
|
||||
|
||||
examples:
|
||||
- input: "n = 4"
|
||||
output: "10"
|
||||
explanation: "After the 4th day, the total is 1 + 2 + 3 + 4 = 10."
|
||||
- input: "n = 10"
|
||||
output: "37"
|
||||
explanation: "After the 10th day, the total is (1 + 2 + 3 + 4 + 5 + 6 + 7) + (2 + 3 + 4) = 37. Notice that on the 2nd Monday, Hercy only puts in $2."
|
||||
- input: "n = 20"
|
||||
output: "96"
|
||||
explanation: "After the 20th day, the total is (1 + 2 + 3 + 4 + 5 + 6 + 7) + (2 + 3 + 4 + 5 + 6 + 7 + 8) + (3 + 4 + 5 + 6 + 7 + 8) = 96."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Think of this problem like filling a piggy bank with a predictable pattern. Each week forms a mini arithmetic sequence starting from a different base value.
|
||||
|
||||
The **key insight** is recognising the repeating weekly structure. The first week contributes `1 + 2 + 3 + 4 + 5 + 6 + 7 = 28`. The second week contributes `2 + 3 + 4 + 5 + 6 + 7 + 8 = 35`. Each complete week adds `7` more than the previous week (because every day that week starts `$1` higher).
|
||||
|
||||
So if you have `k` complete weeks, you're summing `28, 35, 42, ...` — an arithmetic sequence where each term increases by `7`. After the complete weeks, you may have some leftover days forming a partial week.
|
||||
|
||||
The mental model: **split the problem into complete weeks plus remaining days**, then use arithmetic series formulas to compute each part efficiently.
|
||||
|
||||
approach: |
|
||||
We solve this using a **Mathematical Formula Approach**:
|
||||
|
||||
**Step 1: Calculate complete weeks and remaining days**
|
||||
|
||||
- `complete_weeks`: Divide `n` by `7` to get the number of full weeks
|
||||
- `remaining_days`: Use `n % 7` to get the leftover days in the partial week
|
||||
|
||||
|
||||
|
||||
**Step 2: Sum all complete weeks**
|
||||
|
||||
- The 1<sup>st</sup> week sums to `1 + 2 + ... + 7 = 28`
|
||||
- The 2<sup>nd</sup> week sums to `2 + 3 + ... + 8 = 35`
|
||||
- The k<sup>th</sup> week sums to `28 + 7*(k-1)`
|
||||
- Total for `k` complete weeks: sum of arithmetic sequence with first term `28`, common difference `7`, and `k` terms
|
||||
- Formula: `k * 28 + 7 * (0 + 1 + 2 + ... + (k-1))` = `k * 28 + 7 * k * (k-1) / 2`
|
||||
|
||||
|
||||
|
||||
**Step 3: Sum the remaining days**
|
||||
|
||||
- The partial week starts on the `(complete_weeks + 1)`<sup>th</sup> Monday
|
||||
- First day of partial week = `complete_weeks + 1`
|
||||
- Sum `remaining_days` consecutive values starting from that base
|
||||
- Formula: `remaining_days * base + (0 + 1 + ... + (remaining_days - 1))`
|
||||
- Simplifies to: `remaining_days * base + remaining_days * (remaining_days - 1) / 2`
|
||||
|
||||
|
||||
|
||||
**Step 4: Return total**
|
||||
|
||||
- Add the complete weeks sum and remaining days sum
|
||||
|
||||
common_pitfalls:
|
||||
- title: Simulating Day by Day
|
||||
description: |
|
||||
A naive approach simulates each day individually:
|
||||
|
||||
```python
|
||||
total = 0
|
||||
for day in range(1, n + 1):
|
||||
week = (day - 1) // 7
|
||||
day_of_week = (day - 1) % 7
|
||||
total += week + 1 + day_of_week
|
||||
```
|
||||
|
||||
While this works and passes given the small constraint (`n <= 1000`), it's O(n) when an O(1) mathematical solution exists. Understanding the formula approach builds skills for harder problems where simulation would be too slow.
|
||||
wrong_approach: "Loop through each day"
|
||||
correct_approach: "Use arithmetic series formulas"
|
||||
|
||||
- title: Off-by-One Errors in Week Calculation
|
||||
description: |
|
||||
Be careful with zero-indexing vs one-indexing. Day 1 is Monday of week 1 (not week 0). Day 7 is still week 1, but day 8 is week 2.
|
||||
|
||||
Using `(day - 1) // 7` gives week index starting from 0, while `(day - 1) % 7` gives day-of-week index (0 = Monday, 6 = Sunday).
|
||||
wrong_approach: "Confusing which day belongs to which week"
|
||||
correct_approach: "Use (day - 1) // 7 for zero-indexed week number"
|
||||
|
||||
- title: Forgetting the Base Increment
|
||||
description: |
|
||||
Each week's starting value increases by 1. Week 1 starts at `$1`, week 2 starts at `$2`, etc. The partial week at the end must use `complete_weeks + 1` as its starting value, not `1`.
|
||||
|
||||
For `n = 10`, the partial week (days 8-10) starts at `$2`, not `$1`.
|
||||
|
||||
key_takeaways:
|
||||
- "**Arithmetic series formula**: Sum of `1 + 2 + ... + k` is `k * (k + 1) / 2` — memorise this for many math problems"
|
||||
- "**Pattern recognition**: Breaking a problem into repeating units (weeks) plus a remainder is a common technique"
|
||||
- "**O(1) vs O(n)**: Even when simulation passes, deriving a closed-form solution demonstrates deeper understanding"
|
||||
- "**Related problems**: This pattern appears in problems involving periodic contributions, interest calculations, and cyclic sequences"
|
||||
|
||||
time_complexity: "O(1). We use constant-time arithmetic operations regardless of the input size."
|
||||
space_complexity: "O(1). We only store a few integer variables for calculations."
|
||||
|
||||
solutions:
|
||||
- approach_name: Mathematical Formula
|
||||
is_optimal: true
|
||||
code: |
|
||||
def total_money(n: int) -> int:
|
||||
# Calculate complete weeks and remaining days
|
||||
complete_weeks = n // 7
|
||||
remaining_days = n % 7
|
||||
|
||||
# Sum of complete weeks using arithmetic series
|
||||
# Each week sums to 28 + 7*(week_index) for week_index 0, 1, 2, ...
|
||||
# Total = k*28 + 7*(0 + 1 + ... + (k-1)) = k*28 + 7*k*(k-1)/2
|
||||
weeks_sum = complete_weeks * 28 + 7 * complete_weeks * (complete_weeks - 1) // 2
|
||||
|
||||
# Sum of remaining days in partial week
|
||||
# Base value for the partial week is (complete_weeks + 1)
|
||||
base = complete_weeks + 1
|
||||
# Sum of arithmetic sequence: base, base+1, ..., base+(remaining_days-1)
|
||||
partial_sum = remaining_days * base + remaining_days * (remaining_days - 1) // 2
|
||||
|
||||
return weeks_sum + partial_sum
|
||||
explanation: |
|
||||
**Time Complexity:** O(1) — Only arithmetic operations, no loops.
|
||||
|
||||
**Space Complexity:** O(1) — Only a few integer variables.
|
||||
|
||||
We decompose the problem into complete weeks and a partial week, then apply arithmetic series formulas to compute each sum directly.
|
||||
|
||||
- approach_name: Simulation
|
||||
is_optimal: false
|
||||
code: |
|
||||
def total_money(n: int) -> int:
|
||||
total = 0
|
||||
day = 1
|
||||
|
||||
while day <= n:
|
||||
# Calculate which week we're in (0-indexed)
|
||||
week = (day - 1) // 7
|
||||
# Calculate day of the week (0 = Monday, 6 = Sunday)
|
||||
day_of_week = (day - 1) % 7
|
||||
|
||||
# Amount deposited = base for this week + day offset
|
||||
# Week 0 starts at $1, week 1 at $2, etc.
|
||||
amount = (week + 1) + day_of_week
|
||||
total += amount
|
||||
day += 1
|
||||
|
||||
return total
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — We iterate through each day from 1 to n.
|
||||
|
||||
**Space Complexity:** O(1) — Only tracking a running total.
|
||||
|
||||
This straightforward simulation calculates the deposit for each day based on which week it falls in. While correct, it's less elegant than the formula approach. Useful for verifying the mathematical solution.
|
||||
164
backend/data/questions/camelcase-matching.yaml
Normal file
164
backend/data/questions/camelcase-matching.yaml
Normal file
@@ -0,0 +1,164 @@
|
||||
title: Camelcase Matching
|
||||
slug: camelcase-matching
|
||||
difficulty: medium
|
||||
leetcode_id: 1023
|
||||
leetcode_url: https://leetcode.com/problems/camelcase-matching/
|
||||
categories:
|
||||
- strings
|
||||
- arrays
|
||||
- two-pointers
|
||||
patterns:
|
||||
- two-pointers
|
||||
|
||||
description: |
|
||||
Given an array of strings `queries` and a string `pattern`, return a boolean array `answer` where `answer[i]` is `true` if `queries[i]` matches `pattern`, and `false` otherwise.
|
||||
|
||||
A query word `queries[i]` matches `pattern` if you can insert lowercase English letters into the pattern so that it equals the query. You may insert a character at any position in pattern or you may choose not to insert any characters **at all**.
|
||||
|
||||
constraints: |
|
||||
- `1 <= pattern.length, queries.length <= 100`
|
||||
- `1 <= queries[i].length <= 100`
|
||||
- `queries[i]` and `pattern` consist of English letters
|
||||
|
||||
examples:
|
||||
- input: 'queries = ["FooBar","FooBarTest","FootBall","FrameBuffer","ForceFeedBack"], pattern = "FB"'
|
||||
output: "[true,false,true,true,false]"
|
||||
explanation: '"FooBar" can be generated like this "F" + "oo" + "B" + "ar". "FootBall" can be generated like this "F" + "oot" + "B" + "all". "FrameBuffer" can be generated like this "F" + "rame" + "B" + "uffer".'
|
||||
- input: 'queries = ["FooBar","FooBarTest","FootBall","FrameBuffer","ForceFeedBack"], pattern = "FoBa"'
|
||||
output: "[true,false,true,false,false]"
|
||||
explanation: '"FooBar" can be generated like this "Fo" + "o" + "Ba" + "r". "FootBall" can be generated like this "Fo" + "ot" + "Ba" + "ll".'
|
||||
- input: 'queries = ["FooBar","FooBarTest","FootBall","FrameBuffer","ForceFeedBack"], pattern = "FoBaT"'
|
||||
output: "[false,true,false,false,false]"
|
||||
explanation: '"FooBarTest" can be generated like this "Fo" + "o" + "Ba" + "r" + "T" + "est".'
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Think of this problem as a **subsequence matching** task with a twist: uppercase letters are "anchors" that must match exactly, while lowercase letters are flexible.
|
||||
|
||||
Imagine the pattern as a skeleton and the query as a potential full word. You're checking whether the query could have been built by "expanding" the pattern — inserting lowercase letters wherever you want, but never adding extra uppercase letters.
|
||||
|
||||
The key insight is that **uppercase letters are mandatory checkpoints**. If you encounter an uppercase letter in the query that doesn't match the next expected character in the pattern, the match fails immediately. Lowercase letters in the query, however, can be "absorbed" freely — they might be insertions that don't need to match anything in the pattern.
|
||||
|
||||
Think of it like this: walk through both strings simultaneously. Pattern characters must appear in the query in order, and any uppercase letter in the query that isn't from the pattern breaks the match.
|
||||
|
||||
approach: |
|
||||
We solve this using a **Two Pointer Approach** to check if each query matches the pattern:
|
||||
|
||||
**Step 1: Create a helper function to match one query**
|
||||
|
||||
- Use a pointer `j` to track position in the pattern
|
||||
- Iterate through each character in the query
|
||||
|
||||
|
||||
|
||||
**Step 2: For each character in the query**
|
||||
|
||||
- If `j < len(pattern)` and the current query character equals `pattern[j]`, advance `j` (we matched a pattern character)
|
||||
- Otherwise, if the query character is **uppercase**, return `False` — this is an unmatched uppercase letter, which means the query has an extra "anchor" not in the pattern
|
||||
|
||||
|
||||
|
||||
**Step 3: Check if pattern was fully matched**
|
||||
|
||||
- After processing all query characters, return `True` only if `j == len(pattern)`
|
||||
- If `j` hasn't reached the end, the pattern wasn't fully consumed, so the match fails
|
||||
|
||||
|
||||
|
||||
**Step 4: Apply to all queries**
|
||||
|
||||
- Return a list of boolean results, one for each query
|
||||
|
||||
|
||||
|
||||
This approach works because we greedily match pattern characters in order while ensuring no extra uppercase letters appear in the query.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Ignoring Uppercase Letter Constraints
|
||||
description: |
|
||||
A common mistake is treating this as a simple subsequence problem. If you only check that the pattern is a subsequence of the query, you'll miss cases where the query has extra uppercase letters.
|
||||
|
||||
For example, with `pattern = "FB"` and `query = "ForceFeedBack"`, the pattern `FB` is technically a subsequence, but the query contains extra uppercase letters (`F`, `B` at "ForceFeedBack") that aren't in the pattern.
|
||||
|
||||
The rule is: **any uppercase letter in the query that doesn't match the current pattern character is a deal-breaker**.
|
||||
wrong_approach: "Simple subsequence check"
|
||||
correct_approach: "Track both subsequence matching AND reject unmatched uppercase letters"
|
||||
|
||||
- title: Not Consuming the Entire Pattern
|
||||
description: |
|
||||
Another pitfall is forgetting to verify that the entire pattern was matched. If you reach the end of the query but haven't consumed all of the pattern, the match should fail.
|
||||
|
||||
For example, with `pattern = "FoBaT"` and `query = "FooBar"`, you can match `F`, `o`, `B`, `a`, but there's no `T` in the query. The pattern index won't reach the end, so this should return `False`.
|
||||
wrong_approach: "Only checking query traversal completes"
|
||||
correct_approach: "Verify pattern pointer reaches the end after query traversal"
|
||||
|
||||
- title: Confusing Character Case Rules
|
||||
description: |
|
||||
The rules are asymmetric:
|
||||
- **Pattern characters** (both upper and lower) must appear in the query in order
|
||||
- **Extra lowercase** in the query: OK (these are the "insertions")
|
||||
- **Extra uppercase** in the query: NOT OK (no uppercase can be inserted)
|
||||
|
||||
If you confuse these rules, you might incorrectly allow queries with extra uppercase letters or reject valid matches with extra lowercase letters.
|
||||
|
||||
key_takeaways:
|
||||
- "**Subsequence with constraints**: This is a twist on the classic subsequence problem — uppercase letters act as mandatory anchors"
|
||||
- "**Two-pointer pattern**: Maintain a pointer in the pattern and iterate through the query, advancing the pattern pointer only on matches"
|
||||
- "**Greedy matching**: Match pattern characters as soon as possible; this greedy approach works because we need all pattern characters in order"
|
||||
- "**Character classification matters**: Distinguishing uppercase vs lowercase is the key insight that makes this problem interesting"
|
||||
|
||||
time_complexity: "O(n × m) where `n` is the number of queries and `m` is the maximum length of a query. We process each character of each query exactly once."
|
||||
space_complexity: "O(n) for the output array. The matching itself uses O(1) extra space per query."
|
||||
|
||||
solutions:
|
||||
- approach_name: Two Pointers
|
||||
is_optimal: true
|
||||
code: |
|
||||
def camelMatch(queries: list[str], pattern: str) -> list[bool]:
|
||||
def matches(query: str) -> bool:
|
||||
# Pointer for pattern
|
||||
j = 0
|
||||
for char in query:
|
||||
# If pattern character matches, advance pattern pointer
|
||||
if j < len(pattern) and char == pattern[j]:
|
||||
j += 1
|
||||
# Unmatched uppercase letter = automatic failure
|
||||
elif char.isupper():
|
||||
return False
|
||||
# Pattern must be fully consumed
|
||||
return j == len(pattern)
|
||||
|
||||
return [matches(query) for query in queries]
|
||||
explanation: |
|
||||
**Time Complexity:** O(n × m) — For each of `n` queries, we iterate through up to `m` characters.
|
||||
|
||||
**Space Complexity:** O(n) — Output array of `n` booleans.
|
||||
|
||||
The two-pointer technique efficiently checks each query in a single pass. We greedily match pattern characters while rejecting any query that contains unmatched uppercase letters.
|
||||
|
||||
- approach_name: Regular Expression
|
||||
is_optimal: false
|
||||
code: |
|
||||
import re
|
||||
|
||||
def camelMatch(queries: list[str], pattern: str) -> list[bool]:
|
||||
# Build regex: each pattern char followed by optional lowercase
|
||||
# e.g., "FB" -> "^[a-z]*F[a-z]*B[a-z]*$"
|
||||
regex_parts = ["^[a-z]*"]
|
||||
for char in pattern:
|
||||
regex_parts.append(char)
|
||||
regex_parts.append("[a-z]*")
|
||||
regex_parts.append("$")
|
||||
regex = re.compile("".join(regex_parts))
|
||||
|
||||
return [bool(regex.match(query)) for query in queries]
|
||||
explanation: |
|
||||
**Time Complexity:** O(n × m) — Regex matching is linear in query length.
|
||||
|
||||
**Space Complexity:** O(p) for the compiled regex where `p` is pattern length.
|
||||
|
||||
This approach builds a regex that matches any string where:
|
||||
1. Pattern characters appear in order
|
||||
2. Only lowercase letters can appear between pattern characters
|
||||
|
||||
For `pattern = "FB"`, the regex becomes `^[a-z]*F[a-z]*B[a-z]*$`. This elegantly captures the matching rules but is less intuitive than the two-pointer approach.
|
||||
193
backend/data/questions/can-convert-string-in-k-moves.yaml
Normal file
193
backend/data/questions/can-convert-string-in-k-moves.yaml
Normal file
@@ -0,0 +1,193 @@
|
||||
title: Can Convert String in K Moves
|
||||
slug: can-convert-string-in-k-moves
|
||||
difficulty: medium
|
||||
leetcode_id: 1540
|
||||
leetcode_url: https://leetcode.com/problems/can-convert-string-in-k-moves/
|
||||
categories:
|
||||
- strings
|
||||
- hash-tables
|
||||
patterns:
|
||||
- greedy
|
||||
|
||||
description: |
|
||||
Given two strings `s` and `t`, your goal is to convert `s` into `t` in `k` moves or less.
|
||||
|
||||
During the i<sup>th</sup> (`1 <= i <= k`) move you can:
|
||||
|
||||
- Choose any index `j` (1-indexed) from `s`, such that `1 <= j <= s.length` and `j` has not been chosen in any previous move, and shift the character at that index `i` times.
|
||||
- Do nothing.
|
||||
|
||||
Shifting a character means replacing it by the next letter in the alphabet (wrapping around so that `'z'` becomes `'a'`). Shifting a character by `i` means applying the shift operations `i` times.
|
||||
|
||||
Remember that any index `j` can be picked at most once.
|
||||
|
||||
Return `true` if it's possible to convert `s` into `t` in no more than `k` moves, otherwise return `false`.
|
||||
|
||||
constraints: |
|
||||
- `1 <= s.length, t.length <= 10^5`
|
||||
- `0 <= k <= 10^9`
|
||||
- `s`, `t` contain only lowercase English letters
|
||||
|
||||
examples:
|
||||
- input: 's = "input", t = "ouput", k = 9'
|
||||
output: "true"
|
||||
explanation: "In the 6th move, we shift 'i' 6 times to get 'o'. And in the 7th move we shift 'n' to get 'u'."
|
||||
- input: 's = "abc", t = "bcd", k = 10'
|
||||
output: "false"
|
||||
explanation: "We need to shift each character in s one time to convert it into t. We can shift 'a' to 'b' during the 1st move. However, there is no way to shift the other characters in the remaining moves to obtain t from s."
|
||||
- input: 's = "aab", t = "bbb", k = 27'
|
||||
output: "true"
|
||||
explanation: "In the 1st move, we shift the first 'a' 1 time to get 'b'. In the 27th move, we shift the second 'a' 27 times to get 'b'."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Think of each character transformation as requiring a specific number of "shift credits". To change `'a'` to `'b'`, you need 1 shift. To change `'a'` to `'c'`, you need 2 shifts. And since the alphabet wraps around, changing `'z'` to `'a'` needs 1 shift.
|
||||
|
||||
The key insight is that each move number can only be used **once**. Move 1 gives you 1 shift, move 2 gives you 2 shifts, and so on up to move `k`. But here's the twist: shifting by 27 is the same as shifting by 1 (since the alphabet has 26 letters), shifting by 28 is the same as shifting by 2, and so on.
|
||||
|
||||
So if you need to shift two different characters by 1, you can use move 1 for one and move 27 for the other (since `27 % 26 = 1`). Similarly, move 53 also gives an effective shift of 1.
|
||||
|
||||
The problem becomes: for each required shift amount (1 through 25), count how many characters need that shift. Then check if we have enough moves available to cover all of them. The first character needing shift `d` uses move `d`, the second uses move `d + 26`, the third uses move `d + 52`, and so on.
|
||||
|
||||
approach: |
|
||||
We solve this using a **Counting with Modular Arithmetic** approach:
|
||||
|
||||
**Step 1: Handle base cases**
|
||||
|
||||
- If the lengths of `s` and `t` differ, return `false` immediately — we can't add or remove characters
|
||||
- If `s` equals `t`, return `true` — no moves needed
|
||||
|
||||
|
||||
|
||||
**Step 2: Calculate required shifts for each position**
|
||||
|
||||
- For each index `i`, calculate the shift needed: `(t[i] - s[i] + 26) % 26`
|
||||
- The `+ 26` handles wraparound (e.g., `'a'` to `'z'` needs 25 shifts, not -1)
|
||||
- If the shift is 0, the characters already match — no move needed for this position
|
||||
|
||||
|
||||
|
||||
**Step 3: Count occurrences of each shift amount**
|
||||
|
||||
- Use a hash map (or array of size 26) to count how many positions need each shift amount (1 to 25)
|
||||
- Shift amount 0 means the characters are already equal — skip these
|
||||
|
||||
|
||||
|
||||
**Step 4: Check if enough moves are available**
|
||||
|
||||
- For each shift amount `d` from 1 to 25:
|
||||
- If `count[d]` positions need this shift, the last one will use move `d + 26 * (count[d] - 1)`
|
||||
- If this exceeds `k`, return `false`
|
||||
- If all shifts can be covered, return `true`
|
||||
|
||||
|
||||
|
||||
The formula `d + 26 * (count - 1)` works because: the 1<sup>st</sup> character uses move `d`, the 2<sup>nd</sup> uses move `d + 26`, the 3<sup>rd</sup> uses move `d + 52`, and so on. The n<sup>th</sup> character uses move `d + 26 * (n - 1)`.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Ignoring Modular Equivalence
|
||||
description: |
|
||||
A common mistake is thinking each shift amount (1-25) can only be used once total. But moves repeat every 26 values!
|
||||
|
||||
For example, if `k = 100` and you need shift amount 1 for three characters:
|
||||
- Character 1 uses move 1
|
||||
- Character 2 uses move 27 (since `27 % 26 = 1`)
|
||||
- Character 3 uses move 53
|
||||
- Character 4 would need move 79, and so on
|
||||
|
||||
You can use any shift amount multiple times, as long as the move number doesn't exceed `k`.
|
||||
wrong_approach: "Assuming each shift amount 1-25 can only be used once"
|
||||
correct_approach: "Use moves d, d+26, d+52, ... for the same shift amount d"
|
||||
|
||||
- title: Off-by-One in Counting
|
||||
description: |
|
||||
When calculating the maximum move needed for `count` characters requiring shift `d`, the formula is `d + 26 * (count - 1)`, not `d + 26 * count`.
|
||||
|
||||
The first character uses move `d` (that's `d + 26 * 0`), not `d + 26`. With `count = 1`, you only need move `d`. With `count = 2`, you need moves `d` and `d + 26`.
|
||||
wrong_approach: "Using d + 26 * count as the maximum move"
|
||||
correct_approach: "Using d + 26 * (count - 1) as the maximum move"
|
||||
|
||||
- title: Forgetting Length Check
|
||||
description: |
|
||||
If `s` and `t` have different lengths, conversion is impossible. Shifting only changes characters — it cannot add or remove them. Always check `len(s) == len(t)` first.
|
||||
|
||||
key_takeaways:
|
||||
- "**Modular arithmetic insight**: When values wrap around (like alphabet letters), think in terms of equivalence classes — shift 1, 27, 53, etc. are all equivalent"
|
||||
- "**Counting for greedy allocation**: When multiple items need the same resource, count them and calculate the worst-case requirement"
|
||||
- "**Constraint analysis**: With `k` up to 10^9, you can't iterate through all moves — the counting approach runs in O(n) time"
|
||||
- "**Related problems**: This pattern of modular counting appears in scheduling problems and cyclic resource allocation"
|
||||
|
||||
time_complexity: "O(n). We iterate through both strings once to calculate shifts and counts, where `n` is the length of the strings."
|
||||
space_complexity: "O(1). We use a fixed-size array of 26 elements to count shift frequencies, regardless of input size."
|
||||
|
||||
solutions:
|
||||
- approach_name: Counting with Modular Arithmetic
|
||||
is_optimal: true
|
||||
code: |
|
||||
def can_convert_string(s: str, t: str, k: int) -> bool:
|
||||
# Strings must be same length — we can't add or remove characters
|
||||
if len(s) != len(t):
|
||||
return False
|
||||
|
||||
# Count how many characters need each shift amount (1-25)
|
||||
shift_count = [0] * 26
|
||||
|
||||
for i in range(len(s)):
|
||||
# Calculate shift needed (with wraparound)
|
||||
shift = (ord(t[i]) - ord(s[i]) + 26) % 26
|
||||
|
||||
# Shift of 0 means characters already match — skip
|
||||
if shift > 0:
|
||||
shift_count[shift] += 1
|
||||
|
||||
# Check if we have enough moves for each shift amount
|
||||
for shift in range(1, 26):
|
||||
if shift_count[shift] == 0:
|
||||
continue
|
||||
|
||||
# The nth character needing this shift uses move: shift + 26 * (n-1)
|
||||
# So the last (count-th) character needs move: shift + 26 * (count-1)
|
||||
max_move_needed = shift + 26 * (shift_count[shift] - 1)
|
||||
|
||||
if max_move_needed > k:
|
||||
return False
|
||||
|
||||
return True
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — We iterate through the strings once to count shifts, then check 26 possible shift amounts.
|
||||
|
||||
**Space Complexity:** O(1) — We use a fixed array of 26 integers regardless of input size.
|
||||
|
||||
The key insight is that move `m` provides an effective shift of `m % 26`. So for characters needing shift `d`, we can use moves `d`, `d+26`, `d+52`, etc. We count how many characters need each shift amount, then verify the maximum required move doesn't exceed `k`.
|
||||
|
||||
- approach_name: Hash Map Variant
|
||||
is_optimal: true
|
||||
code: |
|
||||
from collections import defaultdict
|
||||
|
||||
def can_convert_string(s: str, t: str, k: int) -> bool:
|
||||
if len(s) != len(t):
|
||||
return False
|
||||
|
||||
# Track how many times each shift amount is needed
|
||||
shift_count = defaultdict(int)
|
||||
|
||||
for sc, tc in zip(s, t):
|
||||
shift = (ord(tc) - ord(sc)) % 26
|
||||
if shift != 0:
|
||||
shift_count[shift] += 1
|
||||
|
||||
# For each shift amount, check if the last required move exceeds k
|
||||
for shift, count in shift_count.items():
|
||||
# Moves used: shift, shift+26, shift+52, ..., shift+26*(count-1)
|
||||
if shift + 26 * (count - 1) > k:
|
||||
return False
|
||||
|
||||
return True
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Single pass through the strings.
|
||||
|
||||
**Space Complexity:** O(1) — At most 25 unique shift amounts (1-25).
|
||||
|
||||
This variant uses a dictionary instead of a fixed array. The logic is identical, but some find it more readable. The dictionary only stores non-zero counts, which is slightly more memory-efficient when few shift amounts are needed.
|
||||
275
backend/data/questions/can-i-win.yaml
Normal file
275
backend/data/questions/can-i-win.yaml
Normal file
@@ -0,0 +1,275 @@
|
||||
title: Can I Win
|
||||
slug: can-i-win
|
||||
difficulty: medium
|
||||
leetcode_id: 464
|
||||
leetcode_url: https://leetcode.com/problems/can-i-win/
|
||||
categories:
|
||||
- dynamic-programming
|
||||
- recursion
|
||||
patterns:
|
||||
- dynamic-programming
|
||||
- backtracking
|
||||
|
||||
description: |
|
||||
In the "100 game" two players take turns adding, to a running total, any integer from `1` to `10`. The player who first causes the running total to **reach or exceed** 100 wins.
|
||||
|
||||
What if we change the game so that players **cannot** re-use integers?
|
||||
|
||||
For example, two players might take turns drawing from a common pool of numbers from `1` to `15` without replacement until they reach a total `>= 100`.
|
||||
|
||||
Given two integers `maxChoosableInteger` and `desiredTotal`, return `true` if the first player to move can force a win, otherwise, return `false`. Assume both players play **optimally**.
|
||||
|
||||
constraints: |
|
||||
- `1 <= maxChoosableInteger <= 20`
|
||||
- `0 <= desiredTotal <= 300`
|
||||
|
||||
examples:
|
||||
- input: "maxChoosableInteger = 10, desiredTotal = 11"
|
||||
output: "false"
|
||||
explanation: "No matter which integer the first player chooses, they will lose. If the first player chooses 1, the second player can choose 10 and reach a total of 11, which is >= desiredTotal. The same logic applies for any choice the first player makes."
|
||||
- input: "maxChoosableInteger = 10, desiredTotal = 0"
|
||||
output: "true"
|
||||
explanation: "The desired total is already reached (0 >= 0), so the first player wins immediately."
|
||||
- input: "maxChoosableInteger = 10, desiredTotal = 1"
|
||||
output: "true"
|
||||
explanation: "The first player can choose any number >= 1 (e.g., pick 1) to immediately reach or exceed the desired total."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Imagine you're playing a strategic game where you and your opponent take turns picking numbers from a shared pool, racing to reach a target sum. The twist? Once a number is picked, neither player can use it again.
|
||||
|
||||
The core insight is that this is a **minimax game**: you want to maximise your chances of winning while your opponent tries to minimise them. At each turn, you ask: "Is there *any* number I can pick that guarantees I win, no matter how perfectly my opponent plays afterwards?"
|
||||
|
||||
Think of it like chess — you're not just thinking about your next move, but about all possible responses. A winning position means there exists at least one move that puts your opponent in a losing position. A losing position means *every* move you make gives your opponent a winning position.
|
||||
|
||||
The key observation is that the **game state** is fully determined by:
|
||||
1. Which numbers have been used (the "used" set)
|
||||
2. The remaining total needed to win
|
||||
|
||||
Since `maxChoosableInteger <= 20`, we can represent the used numbers as a **bitmask** — a single integer where each bit indicates whether that number has been used. This makes states easy to track and memoize.
|
||||
|
||||
approach: |
|
||||
We solve this using **Memoized Recursion with Bitmask State**:
|
||||
|
||||
**Step 1: Handle edge cases**
|
||||
|
||||
- If `desiredTotal <= 0`, the first player wins immediately (total is already reached)
|
||||
- If the sum of all choosable integers `(1 + 2 + ... + maxChoosableInteger)` is less than `desiredTotal`, neither player can ever reach the target — return `false`
|
||||
|
||||
|
||||
|
||||
**Step 2: Define the recursive state**
|
||||
|
||||
- `used_mask`: A bitmask where bit `i` is set if number `i+1` has been used
|
||||
- `remaining`: The amount still needed to reach or exceed `desiredTotal`
|
||||
- The function returns `true` if the **current player** can force a win from this state
|
||||
|
||||
|
||||
|
||||
**Step 3: Implement the minimax logic**
|
||||
|
||||
- For each unused number `i` (from 1 to `maxChoosableInteger`):
|
||||
- If `i >= remaining`, picking `i` wins immediately — return `true`
|
||||
- Otherwise, pick `i` and check if the *opponent* loses from the resulting state
|
||||
- If the opponent loses (recursive call returns `false`), then we win — return `true`
|
||||
- If no move leads to a win, return `false`
|
||||
|
||||
|
||||
|
||||
**Step 4: Memoize results**
|
||||
|
||||
- Use a dictionary mapping `used_mask` to the result for that state
|
||||
- The `remaining` value is implicitly determined by `used_mask` and the original `desiredTotal`
|
||||
|
||||
|
||||
|
||||
**Step 5: Return the result**
|
||||
|
||||
- Call the recursive function with `used_mask = 0` (no numbers used) and `remaining = desiredTotal`
|
||||
|
||||
common_pitfalls:
|
||||
- title: Forgetting to Memoize
|
||||
description: |
|
||||
Without memoization, the recursion explores the same game states multiple times. With up to 2<sup>20</sup> possible states (each number either used or not), this leads to exponential time complexity.
|
||||
|
||||
For example, the state "numbers 1 and 3 used" can be reached by picking 1 then 3, or 3 then 1. Without caching, we'd recompute the result for this state multiple times.
|
||||
wrong_approach: "Plain recursion without caching"
|
||||
correct_approach: "Use a dictionary or array to memoize results by bitmask state"
|
||||
|
||||
- title: Incorrect Win Condition Logic
|
||||
description: |
|
||||
The current player wins if they can pick a number that **reaches or exceeds** the remaining total. A common mistake is checking `i > remaining` instead of `i >= remaining`.
|
||||
|
||||
Similarly, the recursive logic must check if the *opponent* loses after our move. If `canWin(new_state)` returns `false`, that means the opponent loses, so we win.
|
||||
wrong_approach: "Checking i > remaining or misinterpreting recursive results"
|
||||
correct_approach: "Win if i >= remaining OR if opponent cannot win after our move"
|
||||
|
||||
- title: Not Handling Edge Cases
|
||||
description: |
|
||||
Two edge cases need special handling:
|
||||
|
||||
1. If `desiredTotal <= 0`, the first player wins immediately (the target is already "reached")
|
||||
2. If the sum of all numbers `1 + 2 + ... + n = n*(n+1)/2` is less than `desiredTotal`, it's impossible for anyone to win — return `false`
|
||||
|
||||
Missing these leads to incorrect results or infinite recursion.
|
||||
wrong_approach: "Jumping straight into recursion without checking if the game is already decided"
|
||||
correct_approach: "Check desiredTotal <= 0 and sum < desiredTotal before recursing"
|
||||
|
||||
- title: Using the Wrong State Representation
|
||||
description: |
|
||||
Some attempt to track state as `(remaining, used_mask)`, but `remaining` is redundant — it's fully determined by which numbers have been used. Including it in the state wastes memory and complicates the cache.
|
||||
|
||||
The bitmask alone uniquely identifies the state because `remaining = desiredTotal - sum(used numbers)`.
|
||||
wrong_approach: "Caching with both remaining and used_mask"
|
||||
correct_approach: "Cache only by used_mask; compute remaining from context"
|
||||
|
||||
key_takeaways:
|
||||
- "**Minimax pattern**: In two-player zero-sum games, a position is winning if there exists a move that puts the opponent in a losing position"
|
||||
- "**Bitmask DP**: When tracking subsets of small sets (n <= 20), use bitmasks for efficient state representation and O(1) set operations"
|
||||
- "**Memoization is essential**: Game tree recursion without caching leads to exponential blowup; the number of unique states is bounded by 2<sup>n</sup>"
|
||||
- "**Foundation for game theory problems**: This pattern applies to Nim variants, stone games, and other optimal play problems"
|
||||
|
||||
time_complexity: "O(2^n * n). There are 2^n possible states (subsets of numbers used), and for each state we try up to n choices. Here n = `maxChoosableInteger`."
|
||||
space_complexity: "O(2^n). The memoization dictionary stores results for each of the 2^n possible bitmask states, plus O(n) recursion stack depth."
|
||||
|
||||
solutions:
|
||||
- approach_name: Memoized Recursion with Bitmask
|
||||
is_optimal: true
|
||||
code: |
|
||||
def can_i_win(max_choosable: int, desired_total: int) -> bool:
|
||||
# Edge case: target already reached
|
||||
if desired_total <= 0:
|
||||
return True
|
||||
|
||||
# Edge case: impossible to reach target even with all numbers
|
||||
total_sum = max_choosable * (max_choosable + 1) // 2
|
||||
if total_sum < desired_total:
|
||||
return False
|
||||
|
||||
# Memoization cache: bitmask -> can current player win?
|
||||
memo = {}
|
||||
|
||||
def can_win(used_mask: int, remaining: int) -> bool:
|
||||
# Check cache first
|
||||
if used_mask in memo:
|
||||
return memo[used_mask]
|
||||
|
||||
# Try each unused number
|
||||
for i in range(1, max_choosable + 1):
|
||||
# Check if number i is already used (bit i-1 is set)
|
||||
if used_mask & (1 << (i - 1)):
|
||||
continue
|
||||
|
||||
# If picking i reaches the target, we win
|
||||
if i >= remaining:
|
||||
memo[used_mask] = True
|
||||
return True
|
||||
|
||||
# Pick i and see if opponent loses
|
||||
new_mask = used_mask | (1 << (i - 1))
|
||||
if not can_win(new_mask, remaining - i):
|
||||
# Opponent loses from new state, so we win
|
||||
memo[used_mask] = True
|
||||
return True
|
||||
|
||||
# No winning move found
|
||||
memo[used_mask] = False
|
||||
return False
|
||||
|
||||
return can_win(0, desired_total)
|
||||
explanation: |
|
||||
**Time Complexity:** O(2^n * n) — At most 2^n states, each trying n numbers.
|
||||
|
||||
**Space Complexity:** O(2^n) — Memoization storage for all possible states.
|
||||
|
||||
We use a bitmask to track which numbers have been used. For each state, we try all unused numbers. If any move either reaches the target immediately or puts the opponent in a losing position, we win. Results are cached to avoid recomputation.
|
||||
|
||||
- approach_name: Iterative DP with Bitmask
|
||||
is_optimal: true
|
||||
code: |
|
||||
def can_i_win(max_choosable: int, desired_total: int) -> bool:
|
||||
# Edge cases
|
||||
if desired_total <= 0:
|
||||
return True
|
||||
total_sum = max_choosable * (max_choosable + 1) // 2
|
||||
if total_sum < desired_total:
|
||||
return False
|
||||
|
||||
n = max_choosable
|
||||
# dp[mask] = True if current player wins with this set of used numbers
|
||||
dp = [False] * (1 << n)
|
||||
|
||||
# Process states in order of increasing popcount (fewer numbers used first)
|
||||
# This ensures when we compute dp[mask], all dp[mask | (1 << i)] are ready
|
||||
for mask in range((1 << n) - 1, -1, -1):
|
||||
# Calculate remaining total for this state
|
||||
used_sum = sum(i + 1 for i in range(n) if mask & (1 << i))
|
||||
remaining = desired_total - used_sum
|
||||
|
||||
if remaining <= 0:
|
||||
# Previous player already won by reaching target
|
||||
continue
|
||||
|
||||
# Try each unused number
|
||||
for i in range(n):
|
||||
if mask & (1 << i):
|
||||
continue # Number i+1 already used
|
||||
|
||||
num = i + 1
|
||||
if num >= remaining:
|
||||
# Picking this number wins immediately
|
||||
dp[mask] = True
|
||||
break
|
||||
|
||||
new_mask = mask | (1 << i)
|
||||
if not dp[new_mask]:
|
||||
# Opponent loses from new_mask state
|
||||
dp[mask] = True
|
||||
break
|
||||
|
||||
return dp[0]
|
||||
explanation: |
|
||||
**Time Complexity:** O(2^n * n) — Process each of 2^n states, trying n choices each.
|
||||
|
||||
**Space Complexity:** O(2^n) — DP array for all states.
|
||||
|
||||
This bottom-up approach processes states in reverse order of the bitmask value. States with more numbers used (higher popcount) are naturally computed first due to the reverse iteration, ensuring dependencies are resolved. For each state, we check if any unused number leads to a win.
|
||||
|
||||
- approach_name: Recursive Without Memoization (TLE)
|
||||
is_optimal: false
|
||||
code: |
|
||||
def can_i_win(max_choosable: int, desired_total: int) -> bool:
|
||||
# Edge cases
|
||||
if desired_total <= 0:
|
||||
return True
|
||||
total_sum = max_choosable * (max_choosable + 1) // 2
|
||||
if total_sum < desired_total:
|
||||
return False
|
||||
|
||||
def can_win(used: set, remaining: int) -> bool:
|
||||
# Try each unused number
|
||||
for i in range(1, max_choosable + 1):
|
||||
if i in used:
|
||||
continue
|
||||
|
||||
# Picking i wins immediately
|
||||
if i >= remaining:
|
||||
return True
|
||||
|
||||
# Check if opponent loses after we pick i
|
||||
used.add(i)
|
||||
opponent_wins = can_win(used, remaining - i)
|
||||
used.remove(i) # Backtrack
|
||||
|
||||
if not opponent_wins:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
return can_win(set(), desired_total)
|
||||
explanation: |
|
||||
**Time Complexity:** O(n!) — Without memoization, we explore all permutations of number choices.
|
||||
|
||||
**Space Complexity:** O(n) — Recursion depth and the used set.
|
||||
|
||||
This naive approach uses a set to track used numbers and explores all possibilities without caching. It correctly implements the minimax logic but will time out for larger inputs. Included to demonstrate why memoization is essential — the same game state can be reached through many different move orders.
|
||||
@@ -0,0 +1,169 @@
|
||||
title: Can Make Arithmetic Progression From Sequence
|
||||
slug: can-make-arithmetic-progression-from-sequence
|
||||
difficulty: easy
|
||||
leetcode_id: 1502
|
||||
leetcode_url: https://leetcode.com/problems/can-make-arithmetic-progression-from-sequence/
|
||||
categories:
|
||||
- arrays
|
||||
- sorting
|
||||
patterns:
|
||||
- two-pointers
|
||||
|
||||
description: |
|
||||
A sequence of numbers is called an **arithmetic progression** if the difference between any two consecutive elements is the same.
|
||||
|
||||
Given an array of numbers `arr`, return `true` *if the array can be rearranged to form an arithmetic progression*. Otherwise, return `false`.
|
||||
|
||||
constraints: |
|
||||
- `2 <= arr.length <= 1000`
|
||||
- `-10^6 <= arr[i] <= 10^6`
|
||||
|
||||
examples:
|
||||
- input: "arr = [3,5,1]"
|
||||
output: "true"
|
||||
explanation: "We can reorder the elements as [1,3,5] or [5,3,1] with differences 2 and -2 respectively, between each consecutive elements."
|
||||
- input: "arr = [1,2,4]"
|
||||
output: "false"
|
||||
explanation: "There is no way to reorder the elements to obtain an arithmetic progression."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Think of an arithmetic progression like evenly spaced fence posts — each post is exactly the same distance from its neighbours. When you're given a jumbled pile of posts, you need to check if they *could* be arranged with equal spacing.
|
||||
|
||||
The key insight is that once you **sort** the array, an arithmetic progression will reveal itself naturally. In a sorted AP, every consecutive pair must have the same difference. If even one pair differs, the answer is `false`.
|
||||
|
||||
Why does sorting work? Because an AP is uniquely determined by its smallest element and its common difference. Sorting puts elements in order, and if an AP exists, the sorted array *is* that AP. You just need to verify the spacing is consistent.
|
||||
|
||||
An alternative mathematical insight: in a valid AP, we can compute the expected common difference as `(max - min) / (n - 1)`. Every element should then be exactly at position `min + i * diff` for some index `i`. This leads to an O(n) solution using a hash set.
|
||||
|
||||
approach: |
|
||||
We present the **Sorting Approach** as the primary solution, which is intuitive and efficient for this problem size.
|
||||
|
||||
**Step 1: Handle the trivial case**
|
||||
|
||||
- If the array has only 2 elements, it's always an AP (any two numbers form an AP)
|
||||
- Return `true` immediately
|
||||
|
||||
|
||||
|
||||
**Step 2: Sort the array**
|
||||
|
||||
- Sort `arr` in ascending order
|
||||
- This arranges elements so that if an AP exists, consecutive elements will be adjacent
|
||||
|
||||
|
||||
|
||||
**Step 3: Calculate the common difference**
|
||||
|
||||
- Compute `diff = arr[1] - arr[0]` — this is what every consecutive pair must equal
|
||||
|
||||
|
||||
|
||||
**Step 4: Verify all consecutive pairs**
|
||||
|
||||
- Iterate through the sorted array from index `1` to `n-1`
|
||||
- For each index `i`, check if `arr[i] - arr[i-1] == diff`
|
||||
- If any pair differs, return `false`
|
||||
|
||||
|
||||
|
||||
**Step 5: Return the result**
|
||||
|
||||
- If all pairs have the same difference, return `true`
|
||||
|
||||
common_pitfalls:
|
||||
- title: Forgetting to Sort First
|
||||
description: |
|
||||
A common mistake is checking differences on the **unsorted** array. The problem says the array *can be rearranged*, so order doesn't matter for input.
|
||||
|
||||
For example, `[3, 5, 1]` has differences `[2, -4]` in its original order — not constant! But sorted as `[1, 3, 5]`, the difference is consistently `2`.
|
||||
wrong_approach: "Check consecutive differences without sorting"
|
||||
correct_approach: "Sort first, then check differences"
|
||||
|
||||
- title: Integer Division Pitfall (O(n) approach)
|
||||
description: |
|
||||
When using the O(n) formula-based approach, the common difference is `(max - min) / (n - 1)`. If this doesn't divide evenly, there's no valid AP.
|
||||
|
||||
For example, with `[1, 2, 4]`: `max=4`, `min=1`, `n=3`, so `diff = 3 / 2 = 1.5`. Since we need integer positions, this signals `false`. Forgetting this check leads to incorrect results.
|
||||
wrong_approach: "Use integer division without checking remainder"
|
||||
correct_approach: "Check if (max - min) % (n - 1) == 0 before proceeding"
|
||||
|
||||
- title: Not Handling Duplicates Correctly
|
||||
description: |
|
||||
When all elements are the same (e.g., `[5, 5, 5]`), the common difference is `0`. This is a valid AP! Some solutions incorrectly reject this case.
|
||||
|
||||
In the sorting approach, this works automatically since `arr[i] - arr[i-1] = 0` for all pairs.
|
||||
wrong_approach: "Assuming diff must be non-zero"
|
||||
correct_approach: "Allow diff = 0 for arrays with all identical elements"
|
||||
|
||||
key_takeaways:
|
||||
- "**Sorting reveals structure**: When order doesn't matter in the input, sorting often simplifies the problem by imposing a predictable structure"
|
||||
- "**AP properties**: An arithmetic progression is fully determined by its first term and common difference — use this to validate"
|
||||
- "**Trade-off awareness**: O(n log n) sorting is simple and sufficient here, but O(n) is achievable with hash sets when needed"
|
||||
- "**Edge cases matter**: Arrays of length 2 and arrays with all identical elements are valid APs"
|
||||
|
||||
time_complexity: "O(n log n). Dominated by the sorting step. The subsequent linear scan is O(n)."
|
||||
space_complexity: "O(1) or O(n) depending on sorting implementation. In-place sorts like heapsort use O(1) extra space, while Python's Timsort uses O(n)."
|
||||
|
||||
solutions:
|
||||
- approach_name: Sorting
|
||||
is_optimal: true
|
||||
code: |
|
||||
def can_make_arithmetic_progression(arr: list[int]) -> bool:
|
||||
# Sort to arrange elements in order
|
||||
arr.sort()
|
||||
|
||||
# Calculate the expected common difference
|
||||
diff = arr[1] - arr[0]
|
||||
|
||||
# Check all consecutive pairs have the same difference
|
||||
for i in range(2, len(arr)):
|
||||
if arr[i] - arr[i - 1] != diff:
|
||||
return False
|
||||
|
||||
return True
|
||||
explanation: |
|
||||
**Time Complexity:** O(n log n) — Sorting dominates; the linear scan is O(n).
|
||||
|
||||
**Space Complexity:** O(1) to O(n) — Depends on sorting algorithm (in-place vs. not).
|
||||
|
||||
This approach is clean and intuitive. By sorting, we transform the problem into simply verifying that adjacent differences are constant. The constraint `n <= 1000` makes O(n log n) more than sufficient.
|
||||
|
||||
- approach_name: Hash Set (O(n) Time)
|
||||
is_optimal: false
|
||||
code: |
|
||||
def can_make_arithmetic_progression(arr: list[int]) -> bool:
|
||||
n = len(arr)
|
||||
min_val = min(arr)
|
||||
max_val = max(arr)
|
||||
|
||||
# If all elements are the same, it's a valid AP with diff=0
|
||||
if max_val == min_val:
|
||||
return True
|
||||
|
||||
# Check if common difference would be an integer
|
||||
if (max_val - min_val) % (n - 1) != 0:
|
||||
return False
|
||||
|
||||
diff = (max_val - min_val) // (n - 1)
|
||||
|
||||
# Use a set to check each element is at a valid AP position
|
||||
seen = set()
|
||||
for num in arr:
|
||||
# Each element should be at position: min + k * diff
|
||||
# So (num - min) should be divisible by diff
|
||||
if (num - min_val) % diff != 0:
|
||||
return False
|
||||
|
||||
# Check for duplicates (other than when diff=0)
|
||||
if num in seen:
|
||||
return False
|
||||
seen.add(num)
|
||||
|
||||
return True
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Single pass through array after finding min/max.
|
||||
|
||||
**Space Complexity:** O(n) — Hash set stores all elements.
|
||||
|
||||
This approach avoids sorting by using mathematical properties of APs. In a valid AP, every element must be exactly at `min + k * diff` for some integer `k` in `[0, n-1]`. We verify this using a hash set to also detect duplicates. While asymptotically faster, the sorting approach is often preferred for its simplicity.
|
||||
175
backend/data/questions/can-place-flowers.yaml
Normal file
175
backend/data/questions/can-place-flowers.yaml
Normal file
@@ -0,0 +1,175 @@
|
||||
title: Can Place Flowers
|
||||
slug: can-place-flowers
|
||||
difficulty: easy
|
||||
leetcode_id: 605
|
||||
leetcode_url: https://leetcode.com/problems/can-place-flowers/
|
||||
categories:
|
||||
- arrays
|
||||
patterns:
|
||||
- greedy
|
||||
|
||||
description: |
|
||||
You have a long flowerbed in which some of the plots are planted, and some are not. However, flowers cannot be planted in **adjacent** plots.
|
||||
|
||||
Given an integer array `flowerbed` containing `0`'s and `1`'s, where `0` means empty and `1` means not empty, and an integer `n`, return `true` *if* `n` *new flowers can be planted in the flowerbed without violating the no-adjacent-flowers rule and* `false` *otherwise*.
|
||||
|
||||
constraints: |
|
||||
- `1 <= flowerbed.length <= 2 * 10^4`
|
||||
- `flowerbed[i]` is `0` or `1`
|
||||
- There are no two adjacent flowers in `flowerbed`
|
||||
- `0 <= n <= flowerbed.length`
|
||||
|
||||
examples:
|
||||
- input: "flowerbed = [1,0,0,0,1], n = 1"
|
||||
output: "true"
|
||||
explanation: "We can plant one flower at index 2. The flowerbed becomes [1,0,1,0,1], which satisfies the no-adjacent-flowers rule."
|
||||
- input: "flowerbed = [1,0,0,0,1], n = 2"
|
||||
output: "false"
|
||||
explanation: "There is only one valid spot (index 2) to plant a flower. We cannot plant 2 flowers without violating the adjacency rule."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Imagine walking through a garden path where each plot is either empty (`0`) or has a flower (`1`). Your task is to plant as many flowers as possible without placing two flowers next to each other.
|
||||
|
||||
The key insight is that we can make a **greedy decision** at each empty plot: if we *can* plant a flower here (no adjacent flowers), we *should* plant it immediately. There's never a benefit to "saving" a spot for later — planting now can only help us, never hurt us.
|
||||
|
||||
Think of it like this: you're walking left to right. At each empty plot, you check three things:
|
||||
- Is the plot to my left empty (or am I at the start)?
|
||||
- Is the current plot empty?
|
||||
- Is the plot to my right empty (or am I at the end)?
|
||||
|
||||
If all three conditions are true, plant a flower and move on. By greedily planting whenever possible, you guarantee the maximum number of flowers.
|
||||
|
||||
approach: |
|
||||
We solve this using a **Single Pass Greedy Approach**:
|
||||
|
||||
**Step 1: Initialise a counter**
|
||||
|
||||
- `count`: Set to `0` to track how many flowers we've planted
|
||||
|
||||
|
||||
|
||||
**Step 2: Iterate through the flowerbed**
|
||||
|
||||
- For each position `i`, check if we can plant a flower:
|
||||
- Current plot must be empty: `flowerbed[i] == 0`
|
||||
- Left neighbour must be empty or out of bounds: `i == 0` or `flowerbed[i-1] == 0`
|
||||
- Right neighbour must be empty or out of bounds: `i == len(flowerbed) - 1` or `flowerbed[i+1] == 0`
|
||||
- If all conditions are met, plant the flower:
|
||||
- Set `flowerbed[i] = 1` (this prevents planting at `i+1`)
|
||||
- Increment `count`
|
||||
|
||||
|
||||
|
||||
**Step 3: Early termination (optimisation)**
|
||||
|
||||
- If `count >= n` at any point, we can return `True` immediately
|
||||
- No need to continue checking once we've planted enough flowers
|
||||
|
||||
|
||||
|
||||
**Step 4: Return the result**
|
||||
|
||||
- After the loop, return `count >= n`
|
||||
|
||||
|
||||
|
||||
This greedy approach works because planting a flower at the earliest valid position never prevents us from achieving the maximum count — it only restricts the immediately adjacent plot, which wouldn't have been valid anyway.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Forgetting Boundary Conditions
|
||||
description: |
|
||||
The first and last plots only have one neighbour to check, not two.
|
||||
|
||||
For position `0`, there is no left neighbour — treat it as empty. Similarly, for the last position, there is no right neighbour.
|
||||
|
||||
Failing to handle boundaries leads to index-out-of-bounds errors or incorrect results for flowerbeds like `[0,0,1]` where the first plot is plantable.
|
||||
wrong_approach: "Always checking both neighbours without boundary checks"
|
||||
correct_approach: "Use `i == 0` or `i == len-1` to handle edge positions"
|
||||
|
||||
- title: Not Marking Planted Flowers
|
||||
description: |
|
||||
After deciding to plant at position `i`, you must update `flowerbed[i] = 1`.
|
||||
|
||||
If you only increment a counter without modifying the array, the next iteration will incorrectly think position `i+1` has an empty left neighbour. This leads to planting adjacent flowers.
|
||||
|
||||
Example: In `[0,0,0]`, if you plant at index `0` but don't mark it, you'll also "plant" at index `1`, violating the adjacency rule.
|
||||
wrong_approach: "Only counting without modifying the array"
|
||||
correct_approach: "Set flowerbed[i] = 1 after planting"
|
||||
|
||||
- title: Checking n > 0 Instead of count >= n
|
||||
description: |
|
||||
When `n = 0`, the answer should always be `True` — you don't need to plant any flowers.
|
||||
|
||||
Some implementations decrement `n` each time they plant, then check `n <= 0`. This works, but be careful with the initial check: if `n == 0` from the start, return `True` immediately or ensure your loop handles it correctly.
|
||||
|
||||
key_takeaways:
|
||||
- "**Greedy validity**: When a locally optimal choice (plant now) never hurts the global solution, greedy works"
|
||||
- "**In-place modification**: Updating the input array lets us track state without extra space"
|
||||
- "**Boundary handling**: Always consider edge cases at array boundaries — they often have special rules"
|
||||
- "**Early termination**: Once you've achieved the goal (`count >= n`), stop early for efficiency"
|
||||
|
||||
time_complexity: "O(n). We traverse the flowerbed array once, where `n` is the length of the array."
|
||||
space_complexity: "O(1). We modify the input array in-place and use only a constant amount of extra space for the counter."
|
||||
|
||||
solutions:
|
||||
- approach_name: Single Pass Greedy
|
||||
is_optimal: true
|
||||
code: |
|
||||
def can_place_flowers(flowerbed: list[int], n: int) -> bool:
|
||||
count = 0
|
||||
length = len(flowerbed)
|
||||
|
||||
for i in range(length):
|
||||
# Check if current plot is empty
|
||||
if flowerbed[i] == 0:
|
||||
# Check left neighbour (or start of array)
|
||||
left_empty = (i == 0) or (flowerbed[i - 1] == 0)
|
||||
# Check right neighbour (or end of array)
|
||||
right_empty = (i == length - 1) or (flowerbed[i + 1] == 0)
|
||||
|
||||
# If both neighbours are empty, plant here
|
||||
if left_empty and right_empty:
|
||||
flowerbed[i] = 1 # Mark as planted
|
||||
count += 1
|
||||
|
||||
# Early exit if we've planted enough
|
||||
if count >= n:
|
||||
return True
|
||||
|
||||
return count >= n
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Single pass through the flowerbed.
|
||||
|
||||
**Space Complexity:** O(1) — We modify the input array in-place and use only a counter variable.
|
||||
|
||||
We iterate through each plot once. When we find a valid spot (current empty, left empty or boundary, right empty or boundary), we plant and mark it. The modification prevents consecutive plantings. Early termination provides a small optimisation when `n` is small.
|
||||
|
||||
- approach_name: Count Consecutive Zeros
|
||||
is_optimal: false
|
||||
code: |
|
||||
def can_place_flowers(flowerbed: list[int], n: int) -> bool:
|
||||
# Pad with zeros for easier boundary handling
|
||||
padded = [0] + flowerbed + [0]
|
||||
count = 0
|
||||
zeros = 0
|
||||
|
||||
for plot in padded:
|
||||
if plot == 0:
|
||||
zeros += 1
|
||||
else:
|
||||
# Calculate flowers that fit in this gap
|
||||
# For k consecutive zeros, we can fit (k-1)//2 flowers
|
||||
count += (zeros - 1) // 2
|
||||
zeros = 0
|
||||
|
||||
# Don't forget the trailing zeros
|
||||
count += (zeros - 1) // 2
|
||||
|
||||
return count >= n
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Single pass through the padded array.
|
||||
|
||||
**Space Complexity:** O(n) — We create a new padded array.
|
||||
|
||||
This approach counts consecutive zeros between flowers. For `k` consecutive empty plots (including virtual padding), we can plant `(k-1) // 2` flowers. The padding simplifies boundary handling. While correct, this uses extra space compared to the in-place greedy approach.
|
||||
@@ -0,0 +1,168 @@
|
||||
title: Can You Eat Your Favorite Candy on Your Favorite Day?
|
||||
slug: can-you-eat-your-favorite-candy-on-your-favorite-day
|
||||
difficulty: medium
|
||||
leetcode_id: 1744
|
||||
leetcode_url: https://leetcode.com/problems/can-you-eat-your-favorite-candy-on-your-favorite-day/
|
||||
categories:
|
||||
- arrays
|
||||
patterns:
|
||||
- prefix-sum
|
||||
|
||||
description: |
|
||||
You are given a **(0-indexed)** array of positive integers `candiesCount` where `candiesCount[i]` represents the number of candies of the i<sup>th</sup> type you have. You are also given a 2D array `queries` where `queries[i] = [favoriteType_i, favoriteDay_i, dailyCap_i]`.
|
||||
|
||||
You play a game with the following rules:
|
||||
|
||||
- You start eating candies on day `0`.
|
||||
- You **cannot** eat any candy of type `i` unless you have eaten **all** candies of type `i - 1`.
|
||||
- You must eat **at least one** candy per day until you have eaten all the candies.
|
||||
|
||||
Construct a boolean array `answer` such that `answer.length == queries.length` and `answer[i]` is `true` if you can eat a candy of type `favoriteType_i` on day `favoriteDay_i` without eating **more than** `dailyCap_i` candies on **any** day, and `false` otherwise.
|
||||
|
||||
Note that you can eat different types of candy on the same day, provided that you follow rule 2.
|
||||
|
||||
Return *the constructed array* `answer`.
|
||||
|
||||
constraints: |
|
||||
- `1 <= candiesCount.length <= 10^5`
|
||||
- `1 <= candiesCount[i] <= 10^5`
|
||||
- `1 <= queries.length <= 10^5`
|
||||
- `queries[i].length == 3`
|
||||
- `0 <= favoriteType_i < candiesCount.length`
|
||||
- `0 <= favoriteDay_i <= 10^9`
|
||||
- `1 <= dailyCap_i <= 10^9`
|
||||
|
||||
examples:
|
||||
- input: "candiesCount = [7,4,5,3,8], queries = [[0,2,2],[4,2,4],[2,13,1000000000]]"
|
||||
output: "[true,false,true]"
|
||||
explanation: |
|
||||
1. If you eat 2 candies (type 0) on day 0 and 2 candies (type 0) on day 1, you will eat a candy of type 0 on day 2.
|
||||
2. You can eat at most 4 candies each day. If you eat 4 candies every day, you will eat 4 candies (type 0) on day 0 and 4 candies (type 0 and type 1) on day 1. On day 2, you can only eat 4 candies (type 1 and type 2), so you cannot eat a candy of type 4 on day 2.
|
||||
3. If you eat 1 candy each day, you will eat a candy of type 2 on day 13.
|
||||
- input: "candiesCount = [5,2,6,4,1], queries = [[3,1,2],[4,10,3],[3,10,100],[4,100,30],[1,3,1]]"
|
||||
output: "[false,true,true,false,false]"
|
||||
explanation: "Each query is evaluated independently based on the range of candies that can be eaten by the given day."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Imagine you're eating through a long sequence of candies arranged by type. Types are eaten in order (you must finish all type 0 before starting type 1, and so on). Each day you can eat between 1 and `dailyCap` candies.
|
||||
|
||||
The key insight is that on any given day, there's a **range of possible candies** you could be eating, depending on how fast or slow you eat:
|
||||
|
||||
- **Minimum candies eaten**: If you eat exactly 1 candy per day, by day `d` you've eaten `d + 1` candies (days are 0-indexed).
|
||||
- **Maximum candies eaten**: If you eat `dailyCap` candies every day, by day `d` you've eaten `(d + 1) * dailyCap` candies.
|
||||
|
||||
For you to eat a candy of your favorite type on day `d`, there must be **overlap** between:
|
||||
1. The range of candies you *could* have eaten by day `d`: `[d + 1, (d + 1) * dailyCap]`
|
||||
2. The range of candies that belong to your favorite type: `[prefix[type - 1] + 1, prefix[type]]`
|
||||
|
||||
If these two ranges overlap, you can adjust your eating speed to hit a candy of your favorite type exactly on that day. If they don't overlap, it's impossible.
|
||||
|
||||
approach: |
|
||||
We solve this using **Prefix Sums** to precompute candy ranges efficiently:
|
||||
|
||||
**Step 1: Build the prefix sum array**
|
||||
|
||||
- Create `prefix[i]` representing the total number of candies from type `0` through type `i`
|
||||
- This lets us quickly determine where each candy type begins and ends in the sequence
|
||||
|
||||
|
||||
|
||||
**Step 2: For each query, determine the candy range for the favorite type**
|
||||
|
||||
- `candies_before`: Total candies before the favorite type = `prefix[type - 1]` (or `0` if type is `0`)
|
||||
- `candies_up_to`: Total candies up to and including the favorite type = `prefix[type]`
|
||||
- The favorite type occupies candies in the range `(candies_before, candies_up_to]`
|
||||
|
||||
|
||||
|
||||
**Step 3: Determine the range of candies reachable on the favorite day**
|
||||
|
||||
- `min_candies`: Eating 1 candy per day means `day + 1` candies eaten by end of day (0-indexed)
|
||||
- `max_candies`: Eating at maximum capacity means `(day + 1) * dailyCap` candies eaten
|
||||
|
||||
|
||||
|
||||
**Step 4: Check for overlap between the two ranges**
|
||||
|
||||
- Two ranges `[a, b]` and `[c, d]` overlap if `a <= d` and `c <= b`
|
||||
- If `min_candies <= candies_up_to` AND `candies_before < max_candies`, return `true`
|
||||
- Otherwise, return `false`
|
||||
|
||||
|
||||
|
||||
The prefix sum allows O(1) range lookups per query, making the overall solution efficient.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Off-by-One Errors with Days
|
||||
description: |
|
||||
Days are 0-indexed, meaning on day 0 you've had 1 opportunity to eat (not 0). By the end of day `d`, you've eaten at minimum `d + 1` candies, not `d`.
|
||||
|
||||
This off-by-one error is easy to make and will cause incorrect results for edge cases where the day number is critical.
|
||||
wrong_approach: "Using `day` instead of `day + 1` for minimum candies"
|
||||
correct_approach: "Remember day 0 means 1 day of eating, so use `day + 1`"
|
||||
|
||||
- title: Integer Overflow
|
||||
description: |
|
||||
With `favoriteDay <= 10^9` and `dailyCap <= 10^9`, the product `(day + 1) * dailyCap` can exceed 32-bit integer limits (up to 10^18).
|
||||
|
||||
In Python this isn't an issue, but in other languages you'd need to use 64-bit integers or handle overflow explicitly.
|
||||
wrong_approach: "Using 32-bit integers for max_candies calculation"
|
||||
correct_approach: "Use 64-bit integers (or Python's arbitrary precision)"
|
||||
|
||||
- title: Boundary Confusion in Range Overlap
|
||||
description: |
|
||||
The candy ranges use different boundary conventions:
|
||||
- Candies reachable: `[min_candies, max_candies]` (inclusive)
|
||||
- Favorite type range: `(candies_before, candies_up_to]` (exclusive start, inclusive end)
|
||||
|
||||
The overlap check needs to account for this. The first candy of the favorite type is `candies_before + 1`, so we check `candies_before < max_candies` (strict inequality).
|
||||
wrong_approach: "Using `candies_before <= max_candies` with inclusive boundaries"
|
||||
correct_approach: "Use strict inequality for the exclusive boundary"
|
||||
|
||||
key_takeaways:
|
||||
- "**Prefix sums for range queries**: Precomputing cumulative sums allows O(1) lookups for any range, turning O(n) per query into O(1)"
|
||||
- "**Range overlap pattern**: Many problems reduce to checking if two intervals overlap — master the condition `a <= d and c <= b`"
|
||||
- "**Model the problem mathematically**: Converting 'can I eat candy X on day Y' into range overlap makes the solution clear"
|
||||
- "**Watch for off-by-one errors**: 0-indexed vs 1-indexed, inclusive vs exclusive boundaries — these details matter"
|
||||
|
||||
time_complexity: "O(n + q). Building the prefix sum takes O(n), and answering each of the q queries takes O(1)."
|
||||
space_complexity: "O(n). We store the prefix sum array of size n. The output array of size q is required regardless."
|
||||
|
||||
solutions:
|
||||
- approach_name: Prefix Sum with Range Overlap
|
||||
is_optimal: true
|
||||
code: |
|
||||
def canEat(candiesCount: list[int], queries: list[list[int]]) -> list[bool]:
|
||||
n = len(candiesCount)
|
||||
|
||||
# Build prefix sum: prefix[i] = total candies from type 0 to type i
|
||||
prefix = [0] * n
|
||||
prefix[0] = candiesCount[0]
|
||||
for i in range(1, n):
|
||||
prefix[i] = prefix[i - 1] + candiesCount[i]
|
||||
|
||||
result = []
|
||||
for favorite_type, favorite_day, daily_cap in queries:
|
||||
# Candies of favorite type occupy the range (candies_before, candies_up_to]
|
||||
candies_before = prefix[favorite_type - 1] if favorite_type > 0 else 0
|
||||
candies_up_to = prefix[favorite_type]
|
||||
|
||||
# By end of favorite_day, we've eaten between min and max candies
|
||||
# (days are 0-indexed, so day 0 means 1 day of eating)
|
||||
min_candies = favorite_day + 1 # eat 1 candy per day
|
||||
max_candies = (favorite_day + 1) * daily_cap # eat at max capacity
|
||||
|
||||
# Check if ranges overlap:
|
||||
# We can eat favorite type if our reachable range overlaps the type's range
|
||||
# Overlap condition: min_candies <= candies_up_to AND candies_before < max_candies
|
||||
can_eat = min_candies <= candies_up_to and candies_before < max_candies
|
||||
result.append(can_eat)
|
||||
|
||||
return result
|
||||
explanation: |
|
||||
**Time Complexity:** O(n + q) — O(n) to build prefix sum, O(1) per query.
|
||||
|
||||
**Space Complexity:** O(n) — For the prefix sum array.
|
||||
|
||||
We precompute the cumulative candy count, then for each query we check if the range of candies reachable on the given day overlaps with the range of candies belonging to the favorite type. The key insight is that eating speed determines a range, and we need that range to intersect with our target.
|
||||
199
backend/data/questions/candy.yaml
Normal file
199
backend/data/questions/candy.yaml
Normal file
@@ -0,0 +1,199 @@
|
||||
title: Candy
|
||||
slug: candy
|
||||
difficulty: hard
|
||||
leetcode_id: 135
|
||||
leetcode_url: https://leetcode.com/problems/candy/
|
||||
categories:
|
||||
- arrays
|
||||
patterns:
|
||||
- greedy
|
||||
|
||||
description: |
|
||||
There are `n` children standing in a line. Each child is assigned a rating value given in the integer array `ratings`.
|
||||
|
||||
You are giving candies to these children subjected to the following requirements:
|
||||
|
||||
- Each child must have at least one candy.
|
||||
- Children with a higher rating get more candies than their neighbors.
|
||||
|
||||
Return *the minimum number of candies you need to have to distribute the candies to the children*.
|
||||
|
||||
constraints: |
|
||||
- `n == ratings.length`
|
||||
- `1 <= n <= 2 * 10^4`
|
||||
- `0 <= ratings[i] <= 2 * 10^4`
|
||||
|
||||
examples:
|
||||
- input: "ratings = [1,0,2]"
|
||||
output: "5"
|
||||
explanation: "You can allocate to the first, second and third child with 2, 1, 2 candies respectively."
|
||||
- input: "ratings = [1,2,2]"
|
||||
output: "4"
|
||||
explanation: "You can allocate to the first, second and third child with 1, 2, 1 candies respectively. The third child gets 1 candy because it satisfies the above two conditions."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Imagine standing in a line of children where you need to hand out candies fairly based on their ratings. The tricky part is that each child's candy count depends on *both* neighbours — the one to their left and the one to their right.
|
||||
|
||||
The key insight is to **break the problem into two simpler sub-problems**: first, ensure each child has more candies than their *left* neighbour (if they have a higher rating), then ensure each child has more candies than their *right* neighbour (if applicable).
|
||||
|
||||
Think of it like this: in the first pass, you walk left-to-right, only looking backwards. If you have a higher rating than the person behind you, you need at least one more candy than them. In the second pass, you walk right-to-left, only looking backwards in that direction. This time, if you have a higher rating than the person to your right, you need at least one more candy than them — but you also can't lose any candies you already earned from the first pass.
|
||||
|
||||
By handling each direction independently and taking the maximum, we guarantee that both constraints are satisfied simultaneously.
|
||||
|
||||
approach: |
|
||||
We solve this using a **Two-Pass Greedy Approach**:
|
||||
|
||||
**Step 1: Initialise the candies array**
|
||||
|
||||
- Create an array `candies` of length `n`, initialised with `1` for each child
|
||||
- This satisfies the first constraint: every child gets at least one candy
|
||||
|
||||
|
||||
|
||||
**Step 2: Left-to-right pass**
|
||||
|
||||
- Iterate from index `1` to `n-1`
|
||||
- For each child, if `ratings[i] > ratings[i-1]`, they must have more candies than their left neighbour
|
||||
- Set `candies[i] = candies[i-1] + 1` in this case
|
||||
|
||||
|
||||
|
||||
**Step 3: Right-to-left pass**
|
||||
|
||||
- Iterate from index `n-2` down to `0`
|
||||
- For each child, if `ratings[i] > ratings[i+1]`, they must have more candies than their right neighbour
|
||||
- Set `candies[i] = max(candies[i], candies[i+1] + 1)` — we use `max` to preserve any larger value from the left-to-right pass
|
||||
|
||||
|
||||
|
||||
**Step 4: Return the total**
|
||||
|
||||
- Sum all values in `candies` to get the minimum total candies needed
|
||||
|
||||
|
||||
|
||||
This greedy approach works because each pass independently satisfies one direction of the constraint, and taking the maximum at each position ensures both constraints hold.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Single Pass Approach
|
||||
description: |
|
||||
A common mistake is trying to solve this in a single pass by looking at both neighbours simultaneously. This fails because when you're at position `i`, you don't yet know the final candy count for position `i+1`.
|
||||
|
||||
For example, with `ratings = [1, 2, 3, 2, 1]`, a single left-to-right pass gives `[1, 2, 3, 1, 1]`, which violates the constraint at index 2 (rating 3 should have more candies than its right neighbour with rating 2).
|
||||
wrong_approach: "Single pass checking both neighbours"
|
||||
correct_approach: "Two separate passes, one for each direction"
|
||||
|
||||
- title: Forgetting to Take Maximum in Second Pass
|
||||
description: |
|
||||
In the right-to-left pass, simply setting `candies[i] = candies[i+1] + 1` can overwrite a larger value from the first pass, violating the left neighbour constraint.
|
||||
|
||||
For example, with `ratings = [1, 3, 2, 1]`, after the left pass: `[1, 2, 1, 1]`. If we just set values in the right pass, we'd get `[1, 3, 2, 1]`, but position 1 (rating 3) now has fewer candies than needed for its left neighbour constraint. Using `max()` gives `[1, 3, 2, 1]` correctly.
|
||||
wrong_approach: "Overwriting values in the second pass"
|
||||
correct_approach: "Use max(current, neighbour + 1) to preserve larger values"
|
||||
|
||||
- title: Equal Ratings Confusion
|
||||
description: |
|
||||
Children with **equal** ratings don't need to have the same number of candies. The constraint only requires that children with *higher* ratings get more candies than their neighbours.
|
||||
|
||||
For `ratings = [1, 2, 2]`, the valid answer is `[1, 2, 1]` with a total of 4 candies. The two children with rating 2 can have different candy counts because neither is *higher* than the other.
|
||||
|
||||
key_takeaways:
|
||||
- "**Two-pass pattern**: When a decision depends on both left and right context, process each direction independently and combine results"
|
||||
- "**Greedy with local constraints**: Satisfy constraints locally at each step — the combination of both passes guarantees global correctness"
|
||||
- "**Initialise with minimum valid state**: Starting with 1 candy per child ensures the baseline constraint is met before optimising"
|
||||
- "**Related problems**: This two-pass technique appears in problems like Trapping Rain Water, Product of Array Except Self, and other scenarios where each element depends on both neighbours"
|
||||
|
||||
time_complexity: "O(n). We make two passes through the array, each taking O(n) time."
|
||||
space_complexity: "O(n). We use an auxiliary array of size `n` to store the candy count for each child."
|
||||
|
||||
solutions:
|
||||
- approach_name: Two-Pass Greedy
|
||||
is_optimal: true
|
||||
code: |
|
||||
def candy(ratings: list[int]) -> int:
|
||||
n = len(ratings)
|
||||
# Start with 1 candy per child (minimum requirement)
|
||||
candies = [1] * n
|
||||
|
||||
# Left to right: ensure higher rating than left neighbour
|
||||
# gets more candies
|
||||
for i in range(1, n):
|
||||
if ratings[i] > ratings[i - 1]:
|
||||
candies[i] = candies[i - 1] + 1
|
||||
|
||||
# Right to left: ensure higher rating than right neighbour
|
||||
# gets more candies (keep max to preserve left constraint)
|
||||
for i in range(n - 2, -1, -1):
|
||||
if ratings[i] > ratings[i + 1]:
|
||||
candies[i] = max(candies[i], candies[i + 1] + 1)
|
||||
|
||||
# Total candies needed
|
||||
return sum(candies)
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Two linear passes through the array.
|
||||
|
||||
**Space Complexity:** O(n) — Auxiliary array to store candy counts.
|
||||
|
||||
The two-pass approach decouples the left and right neighbour constraints, making each pass simple: just compare with the previous element in the direction of traversal. Taking the maximum in the second pass ensures we don't violate constraints established in the first pass.
|
||||
|
||||
- approach_name: Single Array with Two Passes (Space Optimised Variant)
|
||||
is_optimal: false
|
||||
code: |
|
||||
def candy(ratings: list[int]) -> int:
|
||||
n = len(ratings)
|
||||
if n == 1:
|
||||
return 1
|
||||
|
||||
candies = [1] * n
|
||||
|
||||
# Forward pass
|
||||
for i in range(1, n):
|
||||
if ratings[i] > ratings[i - 1]:
|
||||
candies[i] = candies[i - 1] + 1
|
||||
|
||||
# Backward pass with running sum
|
||||
total = candies[n - 1]
|
||||
for i in range(n - 2, -1, -1):
|
||||
if ratings[i] > ratings[i + 1]:
|
||||
candies[i] = max(candies[i], candies[i + 1] + 1)
|
||||
total += candies[i]
|
||||
|
||||
return total
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Two linear passes.
|
||||
|
||||
**Space Complexity:** O(n) — Same as above, but avoids a separate sum() call.
|
||||
|
||||
This variant computes the sum during the second pass rather than calling `sum()` at the end. The space complexity remains O(n) due to the candies array, but it's slightly more efficient in practice by avoiding an extra iteration.
|
||||
|
||||
- approach_name: Brute Force (Iterative Fixing)
|
||||
is_optimal: false
|
||||
code: |
|
||||
def candy(ratings: list[int]) -> int:
|
||||
n = len(ratings)
|
||||
candies = [1] * n
|
||||
changed = True
|
||||
|
||||
# Keep iterating until no changes needed
|
||||
while changed:
|
||||
changed = False
|
||||
for i in range(n):
|
||||
# Check left neighbour
|
||||
if i > 0 and ratings[i] > ratings[i - 1]:
|
||||
if candies[i] <= candies[i - 1]:
|
||||
candies[i] = candies[i - 1] + 1
|
||||
changed = True
|
||||
# Check right neighbour
|
||||
if i < n - 1 and ratings[i] > ratings[i + 1]:
|
||||
if candies[i] <= candies[i + 1]:
|
||||
candies[i] = candies[i + 1] + 1
|
||||
changed = True
|
||||
|
||||
return sum(candies)
|
||||
explanation: |
|
||||
**Time Complexity:** O(n^2) — In the worst case (strictly decreasing ratings), we need O(n) passes, each taking O(n) time.
|
||||
|
||||
**Space Complexity:** O(n) — Candy array storage.
|
||||
|
||||
This approach repeatedly scans the array, fixing any violations until no more changes are needed. While correct, it's inefficient and can cause TLE on large inputs. It's included to illustrate why the two-pass greedy approach is superior.
|
||||
@@ -0,0 +1,195 @@
|
||||
title: Capacity To Ship Packages Within D Days
|
||||
slug: capacity-to-ship-packages-within-d-days
|
||||
difficulty: medium
|
||||
leetcode_id: 1011
|
||||
leetcode_url: https://leetcode.com/problems/capacity-to-ship-packages-within-d-days/
|
||||
categories:
|
||||
- arrays
|
||||
- binary-search
|
||||
patterns:
|
||||
- binary-search
|
||||
|
||||
description: |
|
||||
A conveyor belt has packages that must be shipped from one port to another within `days` days.
|
||||
|
||||
The i<sup>th</sup> package on the conveyor belt has a weight of `weights[i]`. Each day, we load the ship with packages on the conveyor belt (in the order given by `weights`). We may not load more weight than the maximum weight capacity of the ship.
|
||||
|
||||
Return *the least weight capacity of the ship that will result in all the packages on the conveyor belt being shipped within* `days` *days*.
|
||||
|
||||
constraints: |
|
||||
- `1 <= days <= weights.length <= 5 * 10^4`
|
||||
- `1 <= weights[i] <= 500`
|
||||
|
||||
examples:
|
||||
- input: "weights = [1,2,3,4,5,6,7,8,9,10], days = 5"
|
||||
output: "15"
|
||||
explanation: "A ship capacity of 15 is the minimum to ship all packages in 5 days: Day 1: [1,2,3,4,5], Day 2: [6,7], Day 3: [8], Day 4: [9], Day 5: [10]. The cargo must be shipped in order, so rearranging packages is not allowed."
|
||||
- input: "weights = [3,2,2,4,1,4], days = 3"
|
||||
output: "6"
|
||||
explanation: "A ship capacity of 6 ships all packages in 3 days: Day 1: [3,2], Day 2: [2,4], Day 3: [1,4]."
|
||||
- input: "weights = [1,2,3,1,1], days = 4"
|
||||
output: "3"
|
||||
explanation: "A ship capacity of 3 ships all packages in 4 days: Day 1: [1], Day 2: [2], Day 3: [3], Day 4: [1,1]."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
This problem asks us to find the **minimum ship capacity** that allows us to ship all packages within a given number of days. The key insight is recognising that ship capacity has a **monotonic property** with respect to the number of days required.
|
||||
|
||||
Think of it like this: if a ship with capacity `C` can ship all packages in `D` days, then any ship with capacity greater than `C` can also do it in `D` or fewer days. Conversely, if capacity `C` is insufficient, any smaller capacity will also be insufficient. This monotonicity screams **binary search**.
|
||||
|
||||
Imagine you're adjusting a dial that controls ship capacity. Turn it too low, and you need too many days. Turn it too high, and you waste capacity. The "sweet spot" is the minimum capacity where you just barely fit within the day limit — and binary search efficiently finds that boundary.
|
||||
|
||||
The search space for capacity ranges from `max(weights)` (you must fit the heaviest single package) to `sum(weights)` (ship everything in one day). Binary search narrows this range by testing the midpoint and deciding which half contains the answer.
|
||||
|
||||
approach: |
|
||||
We solve this using **Binary Search on the Answer**:
|
||||
|
||||
**Step 1: Define the search space**
|
||||
|
||||
- `left`: Set to `max(weights)` — the ship must at least carry the heaviest package
|
||||
- `right`: Set to `sum(weights)` — this capacity ships everything in one day
|
||||
|
||||
|
||||
|
||||
**Step 2: Binary search for minimum valid capacity**
|
||||
|
||||
- Calculate `mid = (left + right) // 2` as a candidate capacity
|
||||
- Check if this capacity can ship all packages within `days` days using a helper function
|
||||
- If yes, the answer might be `mid` or smaller, so search left: `right = mid`
|
||||
- If no, we need more capacity, so search right: `left = mid + 1`
|
||||
|
||||
|
||||
|
||||
**Step 3: Implement the feasibility check**
|
||||
|
||||
- Simulate loading the ship day by day
|
||||
- Greedily load packages onto the current day until adding another would exceed capacity
|
||||
- When exceeded, start a new day and reset the current load
|
||||
- Count total days required and compare with the limit
|
||||
|
||||
|
||||
|
||||
**Step 4: Return the result**
|
||||
|
||||
- When `left == right`, we've found the minimum valid capacity
|
||||
- Return `left`
|
||||
|
||||
|
||||
|
||||
This approach works because the feasibility function is monotonic: once a capacity works, all larger capacities also work. Binary search exploits this to find the boundary in O(log(sum - max)) iterations.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Wrong Search Space Boundaries
|
||||
description: |
|
||||
A common mistake is setting `left = 1` or `left = 0`. The minimum possible capacity must be at least `max(weights)` — otherwise, the heaviest package cannot be loaded at all.
|
||||
|
||||
Similarly, setting `right` too high (like `10^9`) wastes iterations. The maximum useful capacity is `sum(weights)`, which ships everything in one day.
|
||||
wrong_approach: "left = 1 or left = 0"
|
||||
correct_approach: "left = max(weights), right = sum(weights)"
|
||||
|
||||
- title: Off-by-One in Binary Search
|
||||
description: |
|
||||
Binary search on the answer requires careful handling of the search condition. Using `left < right` with `right = mid` finds the leftmost valid value. Using `left <= right` with different updates can cause infinite loops.
|
||||
|
||||
The pattern `while left < right` with `right = mid` when feasible and `left = mid + 1` when not feasible correctly converges to the minimum valid capacity.
|
||||
wrong_approach: "Mixing incompatible loop conditions and updates"
|
||||
correct_approach: "Use left < right with right = mid and left = mid + 1"
|
||||
|
||||
- title: Greedy Simulation Error
|
||||
description: |
|
||||
When simulating, you might forget to start with `days_needed = 1`. The first package goes on day 1, so you start counting from 1, not 0.
|
||||
|
||||
Also, when a package doesn't fit on the current day, you must start a new day *and* add that package to it — don't skip the package.
|
||||
wrong_approach: "Starting days_needed = 0 or forgetting to add the package after starting a new day"
|
||||
correct_approach: "Start with days_needed = 1 and current_load = 0, then handle day transitions carefully"
|
||||
|
||||
- title: Linear Search Instead of Binary Search
|
||||
description: |
|
||||
Testing capacities one by one from `max(weights)` upward works but is O(n * (sum - max)), which can be up to O(n * n * max_weight). With `n = 5 * 10^4` and `weights[i] <= 500`, this could mean billions of operations — guaranteed TLE.
|
||||
|
||||
Binary search reduces the capacity search from O(sum - max) to O(log(sum - max)), making the total complexity O(n * log(sum)).
|
||||
wrong_approach: "for capacity in range(max(weights), sum(weights) + 1)"
|
||||
correct_approach: "Binary search on capacity with O(log(sum)) iterations"
|
||||
|
||||
key_takeaways:
|
||||
- "**Binary search on the answer**: When asked to find the minimum/maximum value that satisfies a condition, check if the condition is monotonic — if so, binary search applies"
|
||||
- "**Feasibility check pattern**: The helper function that checks 'can we achieve X with value Y?' is the core of binary search on answer problems"
|
||||
- "**Search space boundaries matter**: Always identify the tightest valid range — here, `[max(weights), sum(weights)]`"
|
||||
- "**Related problems**: This pattern appears in Koko Eating Bananas, Split Array Largest Sum, and Minimum Number of Days to Make m Bouquets"
|
||||
|
||||
time_complexity: "O(n * log(sum(weights))). Binary search runs O(log(sum - max)) iterations, and each feasibility check scans all n packages."
|
||||
space_complexity: "O(1). We only use a constant number of variables for tracking capacity, days, and current load."
|
||||
|
||||
solutions:
|
||||
- approach_name: Binary Search on Answer
|
||||
is_optimal: true
|
||||
code: |
|
||||
def ship_within_days(weights: list[int], days: int) -> int:
|
||||
def can_ship(capacity: int) -> bool:
|
||||
"""Check if we can ship all packages within 'days' using this capacity."""
|
||||
days_needed = 1
|
||||
current_load = 0
|
||||
|
||||
for weight in weights:
|
||||
# If adding this package exceeds capacity, start a new day
|
||||
if current_load + weight > capacity:
|
||||
days_needed += 1
|
||||
current_load = 0
|
||||
|
||||
current_load += weight
|
||||
|
||||
return days_needed <= days
|
||||
|
||||
# Search space: minimum is max single package, maximum ships all in one day
|
||||
left = max(weights)
|
||||
right = sum(weights)
|
||||
|
||||
# Binary search for minimum valid capacity
|
||||
while left < right:
|
||||
mid = (left + right) // 2
|
||||
|
||||
if can_ship(mid):
|
||||
# This capacity works, but maybe we can do better
|
||||
right = mid
|
||||
else:
|
||||
# Need more capacity
|
||||
left = mid + 1
|
||||
|
||||
return left
|
||||
explanation: |
|
||||
**Time Complexity:** O(n * log(sum(weights))) — Binary search over the capacity range, with O(n) feasibility check per iteration.
|
||||
|
||||
**Space Complexity:** O(1) — Only constant extra space used.
|
||||
|
||||
We binary search on the ship capacity. For each candidate capacity, we greedily simulate shipping: load packages day by day until one doesn't fit, then start a new day. If the total days needed is within the limit, the capacity is feasible. We find the minimum feasible capacity.
|
||||
|
||||
- approach_name: Linear Search
|
||||
is_optimal: false
|
||||
code: |
|
||||
def ship_within_days(weights: list[int], days: int) -> int:
|
||||
def can_ship(capacity: int) -> bool:
|
||||
"""Check if we can ship all packages within 'days' using this capacity."""
|
||||
days_needed = 1
|
||||
current_load = 0
|
||||
|
||||
for weight in weights:
|
||||
if current_load + weight > capacity:
|
||||
days_needed += 1
|
||||
current_load = 0
|
||||
|
||||
current_load += weight
|
||||
|
||||
return days_needed <= days
|
||||
|
||||
# Try each capacity starting from minimum possible
|
||||
for capacity in range(max(weights), sum(weights) + 1):
|
||||
if can_ship(capacity):
|
||||
return capacity
|
||||
|
||||
return sum(weights) # Fallback (always works)
|
||||
explanation: |
|
||||
**Time Complexity:** O(n * (sum(weights) - max(weights))) — Linear search over capacities, each with O(n) check.
|
||||
|
||||
**Space Complexity:** O(1) — Only constant extra space used.
|
||||
|
||||
This brute force approach tests every capacity from `max(weights)` upward until finding one that works. While correct, it's far too slow for large inputs. With `n = 5 * 10^4` and sum up to `2.5 * 10^7`, this could require billions of operations. Included to illustrate why binary search is essential.
|
||||
189
backend/data/questions/capitalize-the-title.yaml
Normal file
189
backend/data/questions/capitalize-the-title.yaml
Normal file
@@ -0,0 +1,189 @@
|
||||
title: Capitalize the Title
|
||||
slug: capitalize-the-title
|
||||
difficulty: easy
|
||||
leetcode_id: 2129
|
||||
leetcode_url: https://leetcode.com/problems/capitalize-the-title/
|
||||
categories:
|
||||
- strings
|
||||
patterns:
|
||||
- two-pointers
|
||||
|
||||
description: |
|
||||
You are given a string `title` consisting of one or more words separated by a single space, where each word consists of English letters. **Capitalize** the string by changing the capitalization of each word such that:
|
||||
|
||||
- If the length of the word is `1` or `2` letters, change all letters to lowercase.
|
||||
- Otherwise, change the first letter to uppercase and the remaining letters to lowercase.
|
||||
|
||||
Return *the **capitalized*** `title`.
|
||||
|
||||
constraints: |
|
||||
- `1 <= title.length <= 100`
|
||||
- `title` consists of words separated by a single space without any leading or trailing spaces.
|
||||
- Each word consists of uppercase and lowercase English letters and is **non-empty**.
|
||||
|
||||
examples:
|
||||
- input: 'title = "capiTalIze tHe titLe"'
|
||||
output: '"Capitalize The Title"'
|
||||
explanation: "Since all the words have a length of at least 3, the first letter of each word is uppercase, and the remaining letters are lowercase."
|
||||
- input: 'title = "First leTTeR of EACH Word"'
|
||||
output: '"First Letter of Each Word"'
|
||||
explanation: 'The word "of" has length 2, so it is all lowercase. The remaining words have a length of at least 3, so the first letter of each remaining word is uppercase, and the remaining letters are lowercase.'
|
||||
- input: 'title = "i lOve leetcode"'
|
||||
output: '"i Love Leetcode"'
|
||||
explanation: 'The word "i" has length 1, so it is lowercase. The remaining words have a length of at least 3, so the first letter of each remaining word is uppercase, and the remaining letters are lowercase.'
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Think of this problem like editing a book title according to specific style rules. When you see a title like "tHE quICk FOX", you need to normalise each word based on its length.
|
||||
|
||||
The core insight is that each word operates **independently** — the capitalisation rule for one word doesn't depend on any other word. This means we can process the string word by word.
|
||||
|
||||
Imagine you're a copy editor with a simple checklist:
|
||||
- Is this word tiny (1-2 letters)? Make it all lowercase.
|
||||
- Is this word longer? Capitalise only the first letter, lowercase the rest.
|
||||
|
||||
Since words are separated by single spaces with no leading/trailing spaces, we can simply split the string, transform each word according to our rules, and join them back together.
|
||||
|
||||
approach: |
|
||||
We solve this using a **Word-by-Word Transformation** approach:
|
||||
|
||||
**Step 1: Split the string into words**
|
||||
|
||||
- Use the `split()` method to break the title into individual words
|
||||
- Each word will be processed independently
|
||||
|
||||
|
||||
|
||||
**Step 2: Transform each word based on its length**
|
||||
|
||||
- For each word, check its length:
|
||||
- If `len(word) <= 2`: convert the entire word to lowercase using `lower()`
|
||||
- If `len(word) >= 3`: capitalise using `capitalize()` (first letter uppercase, rest lowercase)
|
||||
|
||||
|
||||
|
||||
**Step 3: Join the transformed words**
|
||||
|
||||
- Use `' '.join()` to combine all transformed words back into a single string
|
||||
- The single space separator preserves the original spacing
|
||||
|
||||
|
||||
|
||||
This approach leverages Python's built-in string methods for clean, readable code.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Forgetting to Lowercase the Rest
|
||||
description: |
|
||||
A common mistake is only uppercasing the first letter without lowercasing the remaining letters.
|
||||
|
||||
For example, with `"HELLO"`, if you only uppercase the first letter, you'd get `"HELLO"` instead of `"Hello"`.
|
||||
|
||||
Python's `capitalize()` method handles this correctly — it uppercases the first character and lowercases all others. Using `title()` would be incorrect as it capitalises every word regardless of length.
|
||||
wrong_approach: "Only uppercase first letter: word[0].upper() + word[1:]"
|
||||
correct_approach: "Use capitalize() which lowercases the rest: word.capitalize()"
|
||||
|
||||
- title: Using title() Method
|
||||
description: |
|
||||
Python's built-in `title()` method might seem perfect, but it capitalises **every** word regardless of length.
|
||||
|
||||
For `"i lOve leetcode"`, using `title()` gives `"I Love Leetcode"` — but "i" should stay lowercase since it has only 1 letter.
|
||||
|
||||
We need custom logic that checks word length before deciding how to capitalise.
|
||||
wrong_approach: 'title.title()'
|
||||
correct_approach: "Check length first, then apply lowercase or capitalize"
|
||||
|
||||
- title: Off-by-One in Length Check
|
||||
description: |
|
||||
The rule specifies words with length `1` or `2` should be lowercase. Make sure your condition is `<= 2` not `< 2`.
|
||||
|
||||
With `len(word) < 2`, you'd only lowercase single-letter words, incorrectly capitalising two-letter words like "of" or "to".
|
||||
wrong_approach: "if len(word) < 2: word.lower()"
|
||||
correct_approach: "if len(word) <= 2: word.lower()"
|
||||
|
||||
key_takeaways:
|
||||
- "**String splitting pattern**: When processing words independently, `split()` and `join()` provide a clean transformation pipeline"
|
||||
- "**Built-in methods**: Python's `capitalize()` handles both uppercasing first and lowercasing rest — know your standard library"
|
||||
- "**Read requirements carefully**: The length threshold (`<= 2`) is crucial; off-by-one errors are common"
|
||||
- "**Simple problems, clean code**: Resist over-engineering; list comprehensions keep this solution readable and Pythonic"
|
||||
|
||||
time_complexity: "O(n). We process each character in the string exactly once during split, transformation, and join operations."
|
||||
space_complexity: "O(n). We create a new list of words and a new result string, both proportional to the input size."
|
||||
|
||||
solutions:
|
||||
- approach_name: Word-by-Word Transformation
|
||||
is_optimal: true
|
||||
code: |
|
||||
def capitalize_title(title: str) -> str:
|
||||
# Split the title into individual words
|
||||
words = title.split()
|
||||
|
||||
# Transform each word based on its length
|
||||
result = []
|
||||
for word in words:
|
||||
if len(word) <= 2:
|
||||
# Short words (1-2 letters) are all lowercase
|
||||
result.append(word.lower())
|
||||
else:
|
||||
# Longer words: first letter uppercase, rest lowercase
|
||||
result.append(word.capitalize())
|
||||
|
||||
# Join words back with single spaces
|
||||
return ' '.join(result)
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — We iterate through all characters once.
|
||||
|
||||
**Space Complexity:** O(n) — We store the transformed words and result string.
|
||||
|
||||
This approach splits the string, processes each word according to the length rules, and joins them back. Python's built-in `capitalize()` handles the case conversion cleanly.
|
||||
|
||||
- approach_name: List Comprehension
|
||||
is_optimal: true
|
||||
code: |
|
||||
def capitalize_title(title: str) -> str:
|
||||
# One-liner using conditional expression in list comprehension
|
||||
return ' '.join(
|
||||
word.lower() if len(word) <= 2 else word.capitalize()
|
||||
for word in title.split()
|
||||
)
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Same as the explicit loop version.
|
||||
|
||||
**Space Complexity:** O(n) — Generator expression still builds the result list.
|
||||
|
||||
This is a more Pythonic version using a generator expression with a conditional. It's concise while remaining readable, applying the same logic as the explicit loop.
|
||||
|
||||
- approach_name: In-Place Character Processing
|
||||
is_optimal: false
|
||||
code: |
|
||||
def capitalize_title(title: str) -> str:
|
||||
# Convert to list for mutability
|
||||
chars = list(title.lower())
|
||||
n = len(chars)
|
||||
|
||||
i = 0
|
||||
while i < n:
|
||||
# Find the start of the next word
|
||||
word_start = i
|
||||
|
||||
# Find the end of the current word
|
||||
while i < n and chars[i] != ' ':
|
||||
i += 1
|
||||
word_end = i
|
||||
|
||||
# Calculate word length
|
||||
word_length = word_end - word_start
|
||||
|
||||
# If word has 3+ characters, capitalize first letter
|
||||
if word_length >= 3:
|
||||
chars[word_start] = chars[word_start].upper()
|
||||
|
||||
# Move past the space
|
||||
i += 1
|
||||
|
||||
return ''.join(chars)
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Single pass through the string.
|
||||
|
||||
**Space Complexity:** O(n) — We convert string to list for mutability.
|
||||
|
||||
This approach processes characters directly, first lowercasing everything, then capitalising the first letter of words with 3+ characters. While it avoids creating intermediate word arrays, it's more complex and doesn't improve actual performance in Python due to string immutability.
|
||||
201
backend/data/questions/car-fleet-ii.yaml
Normal file
201
backend/data/questions/car-fleet-ii.yaml
Normal file
@@ -0,0 +1,201 @@
|
||||
title: Car Fleet II
|
||||
slug: car-fleet-ii
|
||||
difficulty: hard
|
||||
leetcode_id: 1776
|
||||
leetcode_url: https://leetcode.com/problems/car-fleet-ii/
|
||||
categories:
|
||||
- arrays
|
||||
- stack
|
||||
- math
|
||||
patterns:
|
||||
- monotonic-stack
|
||||
|
||||
description: |
|
||||
There are `n` cars traveling at different speeds in the same direction along a one-lane road. You are given an array `cars` of length `n`, where `cars[i] = [position_i, speed_i]` represents:
|
||||
|
||||
- `position_i` is the distance between the i<sup>th</sup> car and the beginning of the road in meters. It is guaranteed that `position_i < position_(i+1)`.
|
||||
- `speed_i` is the initial speed of the i<sup>th</sup> car in meters per second.
|
||||
|
||||
For simplicity, cars can be considered as points moving along the number line. Two cars collide when they occupy the same position. Once a car collides with another car, they unite and form a single car fleet. The cars in the formed fleet will have the same position and the same speed, which is the initial speed of the **slowest** car in the fleet.
|
||||
|
||||
Return an array `answer`, where `answer[i]` is the time, in seconds, at which the i<sup>th</sup> car collides with the next car, or `-1` if the car does not collide with the next car. Answers within `10^-5` of the actual answers are accepted.
|
||||
|
||||
constraints: |
|
||||
- `1 <= cars.length <= 10^5`
|
||||
- `1 <= position_i, speed_i <= 10^6`
|
||||
- `position_i < position_(i+1)`
|
||||
|
||||
examples:
|
||||
- input: "cars = [[1,2],[2,1],[4,3],[7,2]]"
|
||||
output: "[1.00000,-1.00000,3.00000,-1.00000]"
|
||||
explanation: "After exactly one second, the first car will collide with the second car, and form a car fleet with speed 1 m/s. After exactly 3 seconds, the third car will collide with the fourth car, and form a car fleet with speed 2 m/s."
|
||||
- input: "cars = [[3,4],[5,4],[6,3],[9,1]]"
|
||||
output: "[2.00000,1.00000,1.50000,-1.00000]"
|
||||
explanation: "The second car (speed 4) catches the third car (speed 3) at t=1. The third car (speed 3) catches the fourth car (speed 1) at t=1.5. The first car eventually catches up to the merged fleet."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Imagine watching cars on a highway from a helicopter. Faster cars behind slower cars will eventually catch up and merge into a "fleet" that travels at the slower car's speed.
|
||||
|
||||
The key insight is that we need to process cars **from right to left** (from the front of the road to the back). Why? Because when a car catches up to the car ahead, it adopts that car's speed. This means a car might "pass through" a collision — if the car ahead has already merged with an even slower car, the collision time calculation changes.
|
||||
|
||||
Think of it like dominoes falling in reverse. The rightmost car never collides with anything (no car ahead). The second-rightmost car might collide with the rightmost. But here's the trick: if the second-rightmost car collides with the rightmost *after* the rightmost has already merged with something else, we need to recalculate based on the merged fleet's speed.
|
||||
|
||||
This naturally leads to a **monotonic stack** approach. We maintain a stack of cars that a car might potentially collide with. When processing each car, we check if it can catch the car at the top of the stack, and if that collision would happen *before* the stack car itself collides with something ahead (and changes speed).
|
||||
|
||||
approach: |
|
||||
We use a **Monotonic Stack** approach, processing cars from right to left:
|
||||
|
||||
**Step 1: Initialise the result array and stack**
|
||||
|
||||
- `answer`: Array of size `n` initialised to `-1` (default: no collision)
|
||||
- `stack`: Stores indices of cars that could be collision targets
|
||||
|
||||
|
||||
|
||||
**Step 2: Process cars from right to left**
|
||||
|
||||
- For each car `i` from `n-1` down to `0`:
|
||||
- While the stack is not empty, check if car `i` can catch the car at stack top
|
||||
- Calculate collision time: `time = (pos[j] - pos[i]) / (speed[i] - speed[j])`
|
||||
- A collision is only valid if car `i` is faster AND the collision happens before car `j` collides with something else (or car `j` never collides)
|
||||
|
||||
|
||||
|
||||
**Step 3: Validate collision timing**
|
||||
|
||||
- If `speed[i] <= speed[j]`: Car `i` can never catch car `j` — pop from stack
|
||||
- If collision time is valid (before car `j`'s own collision or `j` has no collision): record the time
|
||||
- If collision happens *after* car `j` merges with another fleet: car `j` is no longer a valid target — pop and try the next car on the stack
|
||||
|
||||
|
||||
|
||||
**Step 4: Push current car onto stack**
|
||||
|
||||
- After finding the collision time (or determining no collision), push car `i` onto the stack
|
||||
- Cars on the stack represent potential collision targets for cars further left
|
||||
|
||||
|
||||
|
||||
The stack maintains a monotonic property: cars that are valid collision targets. Invalid candidates get popped when they're no longer reachable.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Processing Left to Right
|
||||
description: |
|
||||
Processing cars from left to right seems intuitive but fails because you don't know the final speed of cars ahead.
|
||||
|
||||
For example, with `cars = [[1,4],[2,3],[3,1]]`:
|
||||
- Car 0 (speed 4) will catch car 1 (speed 3)
|
||||
- But car 1 will catch car 2 (speed 1) first and slow down to speed 1
|
||||
- Now car 0 catches up to a fleet moving at speed 1, not speed 3
|
||||
|
||||
Processing right to left ensures we know each car's "fate" before calculating collisions.
|
||||
wrong_approach: "Iterate left to right and calculate naive collision times"
|
||||
correct_approach: "Iterate right to left using a monotonic stack"
|
||||
|
||||
- title: Ignoring Transitive Collisions
|
||||
description: |
|
||||
When car `i` would collide with car `j` *after* car `j` has already merged with car `k`, the collision time calculation `(pos[j] - pos[i]) / (speed[i] - speed[j])` is wrong.
|
||||
|
||||
At that point, car `j` is traveling at car `k`'s speed. We must pop car `j` from the stack and recalculate against car `k` (or whatever car `j` merged into).
|
||||
|
||||
This is why we check: `collision_time > answer[j]` (when `answer[j] != -1`). If true, the collision happens too late.
|
||||
wrong_approach: "Always use the direct collision time formula"
|
||||
correct_approach: "Pop stack elements when collision happens after their own collision"
|
||||
|
||||
- title: Floating Point Precision
|
||||
description: |
|
||||
Division for collision time can lead to precision issues. The problem accepts answers within `10^-5`, so standard floating-point arithmetic is sufficient, but be careful with edge cases.
|
||||
|
||||
Using `(pos[j] - pos[i]) / (speed[i] - speed[j])` is safe since `speed[i] > speed[j]` is guaranteed when we calculate (we skip cases where `speed[i] <= speed[j]`).
|
||||
|
||||
key_takeaways:
|
||||
- "**Right-to-left processing**: When future states affect current calculations, process in reverse order"
|
||||
- "**Monotonic stack for collision cascades**: The stack tracks valid collision candidates, automatically handling transitive merges"
|
||||
- "**Time-based validity checks**: A collision is only valid if it happens before the target's own state change"
|
||||
- "**Pattern recognition**: This problem combines physics simulation with stack-based optimization — recognizing when brute force O(n^2) can be reduced via monotonic structures"
|
||||
|
||||
time_complexity: "O(n). Each car is pushed onto and popped from the stack at most once, giving amortized O(1) per car."
|
||||
space_complexity: "O(n). The stack can hold up to `n` car indices in the worst case, plus the `answer` array of size `n`."
|
||||
|
||||
solutions:
|
||||
- approach_name: Monotonic Stack
|
||||
is_optimal: true
|
||||
code: |
|
||||
def get_collision_times(cars: list[list[int]]) -> list[float]:
|
||||
n = len(cars)
|
||||
# Initialise all collision times to -1 (no collision)
|
||||
answer = [-1.0] * n
|
||||
# Stack stores indices of cars that could be collision targets
|
||||
stack = []
|
||||
|
||||
# Process cars from right to left (front to back of road)
|
||||
for i in range(n - 1, -1, -1):
|
||||
pos_i, speed_i = cars[i]
|
||||
|
||||
# Check if current car can catch cars on the stack
|
||||
while stack:
|
||||
j = stack[-1] # Car at top of stack
|
||||
pos_j, speed_j = cars[j]
|
||||
|
||||
# If car i is not faster, it can never catch car j
|
||||
if speed_i <= speed_j:
|
||||
stack.pop()
|
||||
continue
|
||||
|
||||
# Calculate when car i would collide with car j
|
||||
collision_time = (pos_j - pos_i) / (speed_i - speed_j)
|
||||
|
||||
# Check if this collision happens before car j's own collision
|
||||
# If car j collides before car i reaches it, j is not a valid target
|
||||
if answer[j] != -1 and collision_time > answer[j]:
|
||||
stack.pop()
|
||||
continue
|
||||
|
||||
# Valid collision found
|
||||
answer[i] = collision_time
|
||||
break
|
||||
|
||||
# Push current car as potential target for cars behind it
|
||||
stack.append(i)
|
||||
|
||||
return answer
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Each car is pushed and popped from the stack at most once.
|
||||
|
||||
**Space Complexity:** O(n) — Stack can hold up to n indices.
|
||||
|
||||
We process cars from right to left, maintaining a stack of potential collision targets. For each car, we find the first valid target it can collide with, accounting for the fact that the target might merge with another fleet before the collision occurs.
|
||||
|
||||
- approach_name: Brute Force (TLE)
|
||||
is_optimal: false
|
||||
code: |
|
||||
def get_collision_times(cars: list[list[int]]) -> list[float]:
|
||||
n = len(cars)
|
||||
answer = [-1.0] * n
|
||||
|
||||
# For each car, simulate its journey
|
||||
for i in range(n - 1):
|
||||
pos_i, speed_i = cars[i]
|
||||
|
||||
# Check all cars ahead
|
||||
min_time = float('inf')
|
||||
for j in range(i + 1, n):
|
||||
pos_j, speed_j = cars[j]
|
||||
|
||||
# Can only catch if faster
|
||||
if speed_i > speed_j:
|
||||
time = (pos_j - pos_i) / (speed_i - speed_j)
|
||||
# This doesn't account for car j merging first!
|
||||
min_time = min(min_time, time)
|
||||
|
||||
if min_time != float('inf'):
|
||||
answer[i] = min_time
|
||||
|
||||
return answer
|
||||
explanation: |
|
||||
**Time Complexity:** O(n^2) — Nested loops checking all pairs.
|
||||
|
||||
**Space Complexity:** O(n) — Only the answer array.
|
||||
|
||||
This brute force approach is incorrect for cases where transitive collisions occur. It also times out for large inputs. Included to illustrate why the monotonic stack approach is necessary — we need to track which cars are still "active" targets.
|
||||
210
backend/data/questions/car-fleet.yaml
Normal file
210
backend/data/questions/car-fleet.yaml
Normal file
@@ -0,0 +1,210 @@
|
||||
title: Car Fleet
|
||||
slug: car-fleet
|
||||
difficulty: medium
|
||||
leetcode_id: 853
|
||||
leetcode_url: https://leetcode.com/problems/car-fleet/
|
||||
categories:
|
||||
- arrays
|
||||
- sorting
|
||||
- stack
|
||||
patterns:
|
||||
- monotonic-stack
|
||||
|
||||
description: |
|
||||
There are `n` cars at given miles away from the starting mile `0`, travelling to reach the mile `target`.
|
||||
|
||||
You are given two integer arrays `position` and `speed`, both of length `n`, where `position[i]` is the starting mile of the i<sup>th</sup> car and `speed[i]` is the speed of the i<sup>th</sup> car in miles per hour.
|
||||
|
||||
A car cannot pass another car, but it can catch up and then travel next to it at the speed of the slower car.
|
||||
|
||||
A **car fleet** is a single car or a group of cars driving next to each other. The speed of the car fleet is the **minimum** speed of any car in the fleet.
|
||||
|
||||
If a car catches up to a car fleet at the mile `target`, it will still be considered as part of the car fleet.
|
||||
|
||||
Return *the number of car fleets that will arrive at the destination*.
|
||||
|
||||
constraints: |
|
||||
- `n == position.length == speed.length`
|
||||
- `1 <= n <= 10^5`
|
||||
- `0 < target <= 10^6`
|
||||
- `0 <= position[i] < target`
|
||||
- All the values of `position` are **unique**
|
||||
- `0 < speed[i] <= 10^6`
|
||||
|
||||
examples:
|
||||
- input: "target = 12, position = [10,8,0,5,3], speed = [2,4,1,1,3]"
|
||||
output: "3"
|
||||
explanation: "The cars starting at 10 (speed 2) and 8 (speed 4) become a fleet, meeting each other at 12. The car starting at 0 (speed 1) does not catch up to any other car, so it is a fleet by itself. The cars starting at 5 (speed 1) and 3 (speed 3) become a fleet, meeting each other at 6."
|
||||
- input: "target = 10, position = [3], speed = [3]"
|
||||
output: "1"
|
||||
explanation: "There is only one car, hence there is only one fleet."
|
||||
- input: "target = 100, position = [0,2,4], speed = [4,2,1]"
|
||||
output: "1"
|
||||
explanation: "The cars starting at 0 (speed 4) and 2 (speed 2) become a fleet at mile 4. Then they catch up to the car at position 4 (speed 1), forming one fleet that reaches the target."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Imagine watching a highway from above where all cars are heading towards the same destination. **A faster car behind a slower car will eventually catch up, but cannot pass** — they merge into a single fleet travelling at the slower speed.
|
||||
|
||||
The key insight is to think about **time to reach the target**. If we calculate how long each car takes to reach the destination (ignoring collisions), we can determine which cars will merge:
|
||||
|
||||
- A car that would arrive *faster* than the car directly ahead will catch up and merge with it
|
||||
- A car that would arrive *slower* than the car ahead will never be caught — it leads its own fleet
|
||||
|
||||
Think of it like this: sort the cars by position from **closest to the target** to **farthest**. Process them in this order. For each car, calculate its arrival time. If this car would arrive later than or at the same time as the car ahead (the one closer to target), it forms or joins a fleet. If it would arrive sooner, it gets blocked by the slower fleet ahead.
|
||||
|
||||
By processing from front to back (closest to farthest from target), we can use a **monotonic stack** to track distinct fleets: each entry on the stack represents a fleet with a specific arrival time. A new car either merges with the fleet at the stack top (if it arrives faster) or starts a new fleet (if it arrives slower).
|
||||
|
||||
approach: |
|
||||
We solve this using a **Sort + Monotonic Stack** approach:
|
||||
|
||||
**Step 1: Calculate time to reach target for each car**
|
||||
|
||||
- For each car, compute: `time = (target - position) / speed`
|
||||
- This represents how long each car would take to reach the target if it could drive unimpeded
|
||||
|
||||
|
||||
|
||||
**Step 2: Sort cars by position in descending order**
|
||||
|
||||
- Pair each car's position with its time-to-target
|
||||
- Sort by position descending (closest to target first)
|
||||
- This lets us process cars in the order they would encounter each other
|
||||
|
||||
|
||||
|
||||
**Step 3: Use a stack to track fleet arrival times**
|
||||
|
||||
- Iterate through sorted cars
|
||||
- For each car, compare its arrival time with the stack top (the fleet ahead):
|
||||
- If the current car's time is **greater** than the top, it cannot catch up — push its time onto the stack (new fleet)
|
||||
- If the current car's time is **less than or equal to** the top, it catches up and merges — do not push (absorbed into existing fleet)
|
||||
|
||||
|
||||
|
||||
**Step 4: Return the stack size**
|
||||
|
||||
- Each entry in the stack represents a distinct fleet
|
||||
- The number of entries is the answer
|
||||
|
||||
common_pitfalls:
|
||||
- title: Not Sorting by Position
|
||||
description: |
|
||||
Processing cars in their given order doesn't work because car interactions depend on **relative positions**. A car at position 3 cannot affect a car at position 10 directly — only cars behind can catch up to cars ahead.
|
||||
|
||||
You must sort by position (descending) to process cars from closest-to-target to farthest. This ensures when we consider a car, all cars ahead of it have already been processed.
|
||||
wrong_approach: "Processing cars in input order"
|
||||
correct_approach: "Sort by position descending before processing"
|
||||
|
||||
- title: Using Speed Instead of Time
|
||||
description: |
|
||||
Comparing speeds directly is misleading. A car with higher speed doesn't always catch up — it depends on how far behind it starts.
|
||||
|
||||
For example, a car at position 8 with speed 4 and a car at position 0 with speed 100: despite the huge speed difference, the car at position 8 reaches target 12 in `(12-8)/4 = 1` hour, while the car at position 0 takes `12/100 = 0.12` hours. The faster car is too far behind to catch up before the target.
|
||||
|
||||
Always convert to **time-to-target** for accurate comparisons.
|
||||
wrong_approach: "Comparing speeds to determine merging"
|
||||
correct_approach: "Compare time-to-target values"
|
||||
|
||||
- title: Simulating Movement Step by Step
|
||||
description: |
|
||||
Trying to simulate the actual movement of cars over time is unnecessarily complex and slow. You don't need to track where cars are at each moment.
|
||||
|
||||
The mathematical insight is that arrival time tells us everything: if car A would arrive before car B (which is ahead), car A will catch up. We don't need to simulate *when* or *where* they merge.
|
||||
wrong_approach: "Time-step simulation of car positions"
|
||||
correct_approach: "Calculate arrival times and compare directly"
|
||||
|
||||
- title: Off-by-One in Fleet Counting
|
||||
description: |
|
||||
When a car merges with a fleet, don't double-count. The merged fleet still counts as **one** fleet, not two. Only increment the count (or push to stack) when a car forms a **new** fleet that cannot catch up to the one ahead.
|
||||
|
||||
key_takeaways:
|
||||
- "**Transform the problem**: Converting positions and speeds into arrival times simplifies the merging logic dramatically"
|
||||
- "**Monotonic stack pattern**: When processing elements in sorted order and tracking 'dominant' values (like slowest arrival times), a monotonic stack efficiently maintains the answer"
|
||||
- "**Sort to establish order**: Many problems involving relative relationships (like cars on a road) require sorting to process elements in a meaningful sequence"
|
||||
- "**Similar problems**: This pattern applies to collision problems, merge intervals based on relative metrics, and scheduling with dependencies"
|
||||
|
||||
time_complexity: "O(n log n). Sorting dominates the complexity. The stack operations are O(n) total since each car is pushed and popped at most once."
|
||||
space_complexity: "O(n). We store the paired (position, time) data and the stack, each of which can hold up to `n` elements."
|
||||
|
||||
solutions:
|
||||
- approach_name: Sort + Monotonic Stack
|
||||
is_optimal: true
|
||||
code: |
|
||||
def car_fleet(target: int, position: list[int], speed: list[int]) -> int:
|
||||
# Pair position with time-to-target, sort by position descending
|
||||
cars = sorted(zip(position, speed), reverse=True)
|
||||
|
||||
stack = [] # Stack of arrival times for each fleet
|
||||
|
||||
for pos, spd in cars:
|
||||
# Calculate time to reach target for this car
|
||||
time = (target - pos) / spd
|
||||
|
||||
# If this car takes longer than the fleet ahead, it forms a new fleet
|
||||
if not stack or time > stack[-1]:
|
||||
stack.append(time)
|
||||
# Otherwise, it catches up and merges (don't push)
|
||||
|
||||
# Each stack entry is a distinct fleet
|
||||
return len(stack)
|
||||
explanation: |
|
||||
**Time Complexity:** O(n log n) — Sorting the cars by position.
|
||||
|
||||
**Space Complexity:** O(n) — Storage for sorted pairs and the stack.
|
||||
|
||||
We sort cars by position (closest to target first), then iterate through them. For each car, we calculate its arrival time. If it would arrive after the car ahead (top of stack), it can't catch up and forms a new fleet. Otherwise, it merges with the existing fleet. The stack size at the end equals the number of fleets.
|
||||
|
||||
- approach_name: Sort + Simple Counter
|
||||
is_optimal: true
|
||||
code: |
|
||||
def car_fleet(target: int, position: list[int], speed: list[int]) -> int:
|
||||
# Pair position with time-to-target, sort by position descending
|
||||
cars = sorted(zip(position, speed), reverse=True)
|
||||
|
||||
fleets = 0
|
||||
slowest_time = 0 # Time of the slowest fleet we've seen
|
||||
|
||||
for pos, spd in cars:
|
||||
time = (target - pos) / spd
|
||||
|
||||
# If this car is slower than all fleets ahead, it leads a new fleet
|
||||
if time > slowest_time:
|
||||
fleets += 1
|
||||
slowest_time = time
|
||||
# Otherwise, it gets absorbed by a slower fleet ahead
|
||||
|
||||
return fleets
|
||||
explanation: |
|
||||
**Time Complexity:** O(n log n) — Sorting the cars by position.
|
||||
|
||||
**Space Complexity:** O(1) — Only tracking two variables (excluding input/sort space).
|
||||
|
||||
This is a space-optimised variant. Since we only need to track the slowest arrival time seen so far (not the full stack), we can use a single variable. A car forms a new fleet only if it would arrive later than all fleets ahead of it.
|
||||
|
||||
- approach_name: Brute Force Simulation
|
||||
is_optimal: false
|
||||
code: |
|
||||
def car_fleet(target: int, position: list[int], speed: list[int]) -> int:
|
||||
n = len(position)
|
||||
# Calculate time to reach target for each car
|
||||
times = [(target - position[i]) / speed[i] for i in range(n)]
|
||||
|
||||
# Sort indices by position descending
|
||||
indices = sorted(range(n), key=lambda i: position[i], reverse=True)
|
||||
|
||||
fleets = 0
|
||||
max_time = 0
|
||||
|
||||
for i in indices:
|
||||
if times[i] > max_time:
|
||||
fleets += 1
|
||||
max_time = times[i]
|
||||
|
||||
return fleets
|
||||
explanation: |
|
||||
**Time Complexity:** O(n log n) — Sorting by position.
|
||||
|
||||
**Space Complexity:** O(n) — Storing times and sorted indices.
|
||||
|
||||
This approach sorts indices rather than creating pairs, which some find more readable. It's functionally equivalent to the simple counter solution but uses more space by keeping the original arrays separate.
|
||||
204
backend/data/questions/car-pooling.yaml
Normal file
204
backend/data/questions/car-pooling.yaml
Normal file
@@ -0,0 +1,204 @@
|
||||
title: Car Pooling
|
||||
slug: car-pooling
|
||||
difficulty: medium
|
||||
leetcode_id: 1094
|
||||
leetcode_url: https://leetcode.com/problems/car-pooling/
|
||||
categories:
|
||||
- arrays
|
||||
- sorting
|
||||
patterns:
|
||||
- prefix-sum
|
||||
- intervals
|
||||
|
||||
description: |
|
||||
There is a car with `capacity` empty seats. The vehicle only drives east (i.e., it cannot turn around and drive west).
|
||||
|
||||
You are given the integer `capacity` and an array `trips` where `trips[i] = [numPassengers_i, from_i, to_i]` indicates that the i<sup>th</sup> trip has `numPassengers_i` passengers and the locations to pick them up and drop them off are `from_i` and `to_i` respectively. The locations are given as the number of kilometers due east from the car's initial location.
|
||||
|
||||
Return `true` *if it is possible to pick up and drop off all passengers for all the given trips*, or `false` *otherwise*.
|
||||
|
||||
constraints: |
|
||||
- `1 <= trips.length <= 1000`
|
||||
- `trips[i].length == 3`
|
||||
- `1 <= numPassengers_i <= 100`
|
||||
- `0 <= from_i < to_i <= 1000`
|
||||
- `1 <= capacity <= 10^5`
|
||||
|
||||
examples:
|
||||
- input: "trips = [[2,1,5],[3,3,7]], capacity = 4"
|
||||
output: "false"
|
||||
explanation: "At location 3, we pick up 3 passengers while still carrying 2 from the first trip. Total = 5 passengers, but capacity is only 4."
|
||||
- input: "trips = [[2,1,5],[3,3,7]], capacity = 5"
|
||||
output: "true"
|
||||
explanation: "At location 3, we have 2 + 3 = 5 passengers which exactly matches capacity. At location 5, the first group exits, leaving us with 3 passengers."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Imagine you're driving a shuttle bus along a straight highway, and passengers get on and off at various mile markers. At any given point, you need to make sure the number of passengers in the car never exceeds its capacity.
|
||||
|
||||
The key insight is that we don't need to simulate the entire journey mile by mile. Instead, we only care about **what happens at specific locations** — the pickup and dropoff points. At each pickup location, passengers get on (increasing the count), and at each dropoff location, passengers get off (decreasing the count).
|
||||
|
||||
Think of it like this: create a timeline of events along the route. Each event is either "+X passengers at location L" (pickup) or "-X passengers at location L" (dropoff). If we process these events in order of location and track the running total, we can check if we ever exceed capacity.
|
||||
|
||||
Since the maximum location is constrained to 1000, we can use a simple **difference array** (or "sweep line" technique) where we mark passenger changes at each location, then compute a prefix sum to get the actual passenger count at each point.
|
||||
|
||||
approach: |
|
||||
We solve this using a **Difference Array (Sweep Line) Approach**:
|
||||
|
||||
**Step 1: Create a difference array**
|
||||
|
||||
- Create an array of size 1001 (since `to_i <= 1000`) initialised to zeros
|
||||
- This array will track the *change* in passenger count at each location
|
||||
|
||||
|
||||
|
||||
**Step 2: Mark passenger changes**
|
||||
|
||||
- For each trip `[numPassengers, from, to]`:
|
||||
- Add `numPassengers` at index `from` (passengers get on)
|
||||
- Subtract `numPassengers` at index `to` (passengers get off)
|
||||
|
||||
|
||||
|
||||
**Step 3: Compute running sum and check capacity**
|
||||
|
||||
- Iterate through the difference array, maintaining a running sum of passengers
|
||||
- At each location, the running sum represents the current number of passengers in the car
|
||||
- If the running sum ever exceeds `capacity`, return `false`
|
||||
|
||||
|
||||
|
||||
**Step 4: Return result**
|
||||
|
||||
- If we complete the iteration without exceeding capacity, return `true`
|
||||
|
||||
|
||||
|
||||
This works because the difference array captures all changes, and the prefix sum at any index gives us the total passengers at that location.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Simulating Every Mile
|
||||
description: |
|
||||
A naive approach might iterate through every location from 0 to max(to_i) and check passengers at each point. While this works, it's unnecessarily complex.
|
||||
|
||||
The difference array approach is cleaner and more efficient — we process each trip once (O(n)) and then sweep through locations once (O(1001) = O(1) since it's bounded).
|
||||
wrong_approach: "Iterating through every location"
|
||||
correct_approach: "Use difference array with bounded sweep"
|
||||
|
||||
- title: Forgetting Dropoff Happens Before Pickup
|
||||
description: |
|
||||
At a given location, passengers being dropped off exit **before** new passengers board. This is implicitly handled by the difference array approach since we're looking at the cumulative effect.
|
||||
|
||||
However, if you're using an event-based approach with sorting, ensure dropoff events at the same location are processed before pickup events. Otherwise, you might incorrectly report a capacity violation.
|
||||
wrong_approach: "Processing pickups before dropoffs at same location"
|
||||
correct_approach: "Dropoffs before pickups, or use difference array"
|
||||
|
||||
- title: Off-by-One with Dropoff Location
|
||||
description: |
|
||||
Passengers exit at the `to` location, meaning they are **not in the car** at that location. Make sure you subtract passengers at index `to`, not `to - 1`.
|
||||
|
||||
For example, with trip `[2, 1, 5]`, passengers are in the car at locations 1, 2, 3, 4 but **not** at location 5.
|
||||
wrong_approach: "Subtracting at to - 1"
|
||||
correct_approach: "Subtract at exactly to"
|
||||
|
||||
key_takeaways:
|
||||
- "**Difference array pattern**: When tracking cumulative changes over intervals, mark the change at start (+) and end (-), then compute prefix sums"
|
||||
- "**Bounded constraints enable simple solutions**: With `to_i <= 1000`, we can use a fixed-size array instead of a more complex data structure"
|
||||
- "**Sweep line technique**: This is a fundamental approach for interval problems — process events in order and maintain running state"
|
||||
- "**Similar problems**: Meeting Rooms II, My Calendar series, and other interval overlap problems use the same core idea"
|
||||
|
||||
time_complexity: "O(n + k) where n is the number of trips and k is the range of locations (at most 1001). Since k is bounded by a constant, this is effectively O(n)."
|
||||
space_complexity: "O(k) where k is the range of locations (1001). This is O(1) in terms of input size since k is bounded by a constant."
|
||||
|
||||
solutions:
|
||||
- approach_name: Difference Array (Sweep Line)
|
||||
is_optimal: true
|
||||
code: |
|
||||
def car_pooling(trips: list[list[int]], capacity: int) -> bool:
|
||||
# Difference array to track passenger changes at each location
|
||||
# Index represents location (0 to 1000)
|
||||
passenger_changes = [0] * 1001
|
||||
|
||||
# Mark pickup (+) and dropoff (-) for each trip
|
||||
for num_passengers, start, end in trips:
|
||||
passenger_changes[start] += num_passengers # Passengers get on
|
||||
passenger_changes[end] -= num_passengers # Passengers get off
|
||||
|
||||
# Sweep through locations, tracking current passengers
|
||||
current_passengers = 0
|
||||
for change in passenger_changes:
|
||||
current_passengers += change
|
||||
# Check if we ever exceed capacity
|
||||
if current_passengers > capacity:
|
||||
return False
|
||||
|
||||
return True
|
||||
explanation: |
|
||||
**Time Complexity:** O(n + k) — Process each trip once, then sweep through k locations. With k bounded at 1001, this is effectively O(n).
|
||||
|
||||
**Space Complexity:** O(k) — Fixed array of size 1001, which is O(1) in terms of input size.
|
||||
|
||||
The difference array elegantly captures all passenger changes. The prefix sum at each index gives us the exact passenger count at that location, letting us verify capacity constraints in a single pass.
|
||||
|
||||
- approach_name: Sort by Location with Events
|
||||
is_optimal: false
|
||||
code: |
|
||||
def car_pooling(trips: list[list[int]], capacity: int) -> bool:
|
||||
# Create events: (location, change in passengers)
|
||||
# Use negative change for dropoffs so they sort before pickups at same location
|
||||
events = []
|
||||
for num_passengers, start, end in trips:
|
||||
events.append((start, num_passengers)) # Pickup (positive)
|
||||
events.append((end, -num_passengers)) # Dropoff (negative)
|
||||
|
||||
# Sort by location, then by change (dropoffs before pickups at same spot)
|
||||
events.sort(key=lambda x: (x[0], x[1]))
|
||||
|
||||
current_passengers = 0
|
||||
for location, change in events:
|
||||
current_passengers += change
|
||||
if current_passengers > capacity:
|
||||
return False
|
||||
|
||||
return True
|
||||
explanation: |
|
||||
**Time Complexity:** O(n log n) — Sorting the 2n events dominates.
|
||||
|
||||
**Space Complexity:** O(n) — Storing 2n events.
|
||||
|
||||
This approach explicitly creates events and sorts them. The key insight is that dropoffs (negative changes) naturally sort before pickups at the same location, correctly handling the constraint that passengers exit before new ones board. While slightly less efficient than the difference array approach, it's more intuitive and works for unbounded location ranges.
|
||||
|
||||
- approach_name: Heap (Priority Queue)
|
||||
is_optimal: false
|
||||
code: |
|
||||
import heapq
|
||||
|
||||
def car_pooling(trips: list[list[int]], capacity: int) -> bool:
|
||||
# Sort trips by start location
|
||||
trips.sort(key=lambda x: x[1])
|
||||
|
||||
# Min-heap of (end_location, num_passengers) for active trips
|
||||
active_trips = []
|
||||
current_passengers = 0
|
||||
|
||||
for num_passengers, start, end in trips:
|
||||
# First, drop off passengers whose trips have ended
|
||||
while active_trips and active_trips[0][0] <= start:
|
||||
_, leaving = heapq.heappop(active_trips)
|
||||
current_passengers -= leaving
|
||||
|
||||
# Pick up new passengers
|
||||
current_passengers += num_passengers
|
||||
if current_passengers > capacity:
|
||||
return False
|
||||
|
||||
# Add this trip to active trips
|
||||
heapq.heappush(active_trips, (end, num_passengers))
|
||||
|
||||
return True
|
||||
explanation: |
|
||||
**Time Complexity:** O(n log n) — Sorting trips and heap operations.
|
||||
|
||||
**Space Complexity:** O(n) — Heap can contain up to n trips.
|
||||
|
||||
This approach processes trips in order of their start location. We use a min-heap to track when each group of passengers should exit, efficiently removing completed trips as we go. This is similar to the Meeting Rooms II problem and demonstrates the heap-based approach to interval scheduling.
|
||||
196
backend/data/questions/card-flipping-game.yaml
Normal file
196
backend/data/questions/card-flipping-game.yaml
Normal file
@@ -0,0 +1,196 @@
|
||||
title: Card Flipping Game
|
||||
slug: card-flipping-game
|
||||
difficulty: medium
|
||||
leetcode_id: 822
|
||||
leetcode_url: https://leetcode.com/problems/card-flipping-game/
|
||||
categories:
|
||||
- arrays
|
||||
- hash-tables
|
||||
patterns:
|
||||
- greedy
|
||||
|
||||
description: |
|
||||
You are given two **0-indexed** integer arrays `fronts` and `backs` of length `n`, where the i<sup>th</sup> card has the positive integer `fronts[i]` printed on the front and `backs[i]` printed on the back. Initially, each card is placed on a table such that the front number is facing up and the other is facing down.
|
||||
|
||||
You may flip over any number of cards (possibly zero).
|
||||
|
||||
After flipping the cards, an integer is considered **good** if it is facing down on some card and **not** facing up on any card.
|
||||
|
||||
Return *the minimum possible good integer after flipping the cards*. If there are no good integers, return `0`.
|
||||
|
||||
constraints: |
|
||||
- `n == fronts.length == backs.length`
|
||||
- `1 <= n <= 1000`
|
||||
- `1 <= fronts[i], backs[i] <= 2000`
|
||||
|
||||
examples:
|
||||
- input: "fronts = [1,2,4,4,7], backs = [1,3,4,1,3]"
|
||||
output: "2"
|
||||
explanation: "If we flip the second card, the face up numbers are [1,3,4,4,7] and the face down are [1,2,4,1,3]. 2 is the minimum good integer as it appears facing down but not facing up."
|
||||
- input: "fronts = [1], backs = [1]"
|
||||
output: "0"
|
||||
explanation: "There are no good integers no matter how we flip the cards, so we return 0."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
The key insight is understanding what makes a number **impossible** to be "good".
|
||||
|
||||
Think about it this way: a number is "good" if we can arrange the cards such that it appears on the back of at least one card but never on the front of any card. We have full control over which side of each card faces up — we can flip any card we want.
|
||||
|
||||
So when is a number **impossible** to hide from the front? Only when a card has the **same number on both sides**. If `fronts[i] == backs[i]`, that number will always be visible face-up, no matter how we flip that card. These numbers are "blocked" — they can never be good.
|
||||
|
||||
For any other number that appears in our arrays, we can always arrange the flips so that it appears only on the back. For example, if number `x` appears on the front of card `i`, we can flip card `i` to hide it. As long as `x` isn't blocked by appearing on a same-number card, we can make it good.
|
||||
|
||||
The strategy becomes clear: find all blocked numbers, then find the minimum number from all fronts and backs that isn't blocked.
|
||||
|
||||
approach: |
|
||||
We solve this using a **Set-based Elimination** approach:
|
||||
|
||||
**Step 1: Identify blocked numbers**
|
||||
|
||||
- Create a set called `blocked` to store numbers that can never be good
|
||||
- Iterate through all cards: if `fronts[i] == backs[i]`, add that number to `blocked`
|
||||
- These are the only numbers that will always be visible face-up regardless of flipping
|
||||
|
||||
|
||||
|
||||
**Step 2: Find the minimum valid candidate**
|
||||
|
||||
- Initialise `result` to infinity (or a large value like `2001` given constraints)
|
||||
- Iterate through all numbers in both `fronts` and `backs` arrays
|
||||
- For each number, if it's not in `blocked`, consider it as a candidate
|
||||
- Track the minimum such candidate
|
||||
|
||||
|
||||
|
||||
**Step 3: Return the result**
|
||||
|
||||
- If we found a valid candidate (result is not infinity), return it
|
||||
- Otherwise, return `0` indicating no good integer exists
|
||||
|
||||
|
||||
|
||||
The greedy choice of taking the minimum works because any non-blocked number can be made good, so we simply pick the smallest one.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Overcomplicating the Flip Logic
|
||||
description: |
|
||||
A common mistake is trying to simulate all possible flip combinations or track which cards are flipped.
|
||||
|
||||
The insight is that we don't need to track flips at all. We only need to identify which numbers are "blocked" (same on both sides of a card). Any non-blocked number can be made good through some sequence of flips.
|
||||
|
||||
For example, if `7` appears on front of card A and back of card B, we can flip card A (hiding the 7) and keep card B unflipped (7 is on the back, not visible). The specific flip sequence doesn't matter — what matters is whether the number is blocked or not.
|
||||
wrong_approach: "Simulating all 2^n flip combinations"
|
||||
correct_approach: "Identify blocked numbers with a set, find minimum non-blocked"
|
||||
|
||||
- title: Only Checking Fronts or Only Backs
|
||||
description: |
|
||||
Some solutions only search for the minimum among the `fronts` array or only among the `backs` array.
|
||||
|
||||
The minimum good integer could come from either array. A number on the back of a card can be exposed (made face-down) simply by not flipping that card. A number on the front can be exposed by flipping that card.
|
||||
|
||||
You must search through all values in both arrays to find the global minimum candidate.
|
||||
wrong_approach: "Only iterating through fronts array"
|
||||
correct_approach: "Checking all values in both fronts and backs"
|
||||
|
||||
- title: Misunderstanding "Good" Definition
|
||||
description: |
|
||||
The definition states a good integer must be facing **down** on some card and **not** facing up on **any** card.
|
||||
|
||||
This means if a number appears face-up on even one card in the final configuration, it cannot be good — even if it's also face-down on another card. The blocked set correctly captures this: if a card has the same number on both sides, that number will always be face-up somewhere.
|
||||
|
||||
key_takeaways:
|
||||
- "**Constraint analysis**: When a value appears on both sides of the same card, it's impossible to hide — this is the key insight"
|
||||
- "**Set-based elimination**: Use a set to track blocked/invalid options, then search remaining candidates"
|
||||
- "**Greedy works when all valid options are achievable**: Since any non-blocked number can be made good, we simply pick the minimum"
|
||||
- "**Don't simulate when you can reason**: Instead of exploring `2^n` flip states, identify the constraint that matters"
|
||||
|
||||
time_complexity: "O(n). We iterate through the arrays twice — once to build the blocked set and once to find the minimum candidate."
|
||||
space_complexity: "O(n). The blocked set can contain at most `n` elements (one per card with matching front and back)."
|
||||
|
||||
solutions:
|
||||
- approach_name: Set-based Elimination
|
||||
is_optimal: true
|
||||
code: |
|
||||
def flipgame(fronts: list[int], backs: list[int]) -> int:
|
||||
# Numbers that appear on both sides of same card are blocked
|
||||
# They will always be visible face-up no matter how we flip
|
||||
blocked = {f for f, b in zip(fronts, backs) if f == b}
|
||||
|
||||
# Find minimum number that isn't blocked
|
||||
result = float('inf')
|
||||
|
||||
# Check all numbers from fronts
|
||||
for num in fronts:
|
||||
if num not in blocked:
|
||||
result = min(result, num)
|
||||
|
||||
# Check all numbers from backs
|
||||
for num in backs:
|
||||
if num not in blocked:
|
||||
result = min(result, num)
|
||||
|
||||
# Return 0 if no valid number found
|
||||
return result if result != float('inf') else 0
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Three linear passes through the arrays.
|
||||
|
||||
**Space Complexity:** O(n) — The blocked set stores at most n elements.
|
||||
|
||||
We first identify all "blocked" numbers (those appearing on both sides of the same card). Then we find the minimum number from either array that isn't blocked. This works because any non-blocked number can be arranged to appear only on the back through appropriate flipping.
|
||||
|
||||
- approach_name: Single Pass with Set
|
||||
is_optimal: true
|
||||
code: |
|
||||
def flipgame(fronts: list[int], backs: list[int]) -> int:
|
||||
# Build blocked set first
|
||||
blocked = {f for f, b in zip(fronts, backs) if f == b}
|
||||
|
||||
# Find minimum non-blocked value across both arrays
|
||||
result = float('inf')
|
||||
for f, b in zip(fronts, backs):
|
||||
if f not in blocked:
|
||||
result = min(result, f)
|
||||
if b not in blocked:
|
||||
result = min(result, b)
|
||||
|
||||
return result if result != float('inf') else 0
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Two passes: one to build the set, one to find minimum.
|
||||
|
||||
**Space Complexity:** O(n) — The blocked set stores at most n elements.
|
||||
|
||||
This is a slightly more compact version that checks both front and back values in a single iteration. The logic is identical — we exclude blocked numbers and find the minimum valid candidate.
|
||||
|
||||
- approach_name: Brute Force Simulation
|
||||
is_optimal: false
|
||||
code: |
|
||||
def flipgame(fronts: list[int], backs: list[int]) -> int:
|
||||
n = len(fronts)
|
||||
result = float('inf')
|
||||
|
||||
# Try all 2^n possible flip combinations
|
||||
for mask in range(1 << n):
|
||||
face_up = set()
|
||||
face_down = []
|
||||
|
||||
for i in range(n):
|
||||
if mask & (1 << i): # Card i is flipped
|
||||
face_up.add(backs[i])
|
||||
face_down.append(fronts[i])
|
||||
else: # Card i is not flipped
|
||||
face_up.add(fronts[i])
|
||||
face_down.append(backs[i])
|
||||
|
||||
# Find minimum good number for this configuration
|
||||
for num in face_down:
|
||||
if num not in face_up:
|
||||
result = min(result, num)
|
||||
|
||||
return result if result != float('inf') else 0
|
||||
explanation: |
|
||||
**Time Complexity:** O(n * 2^n) — Exponential in the number of cards.
|
||||
|
||||
**Space Complexity:** O(n) — Sets and lists for each configuration.
|
||||
|
||||
This approach tries every possible way to flip the cards and checks for good numbers in each configuration. While correct, it's far too slow for the given constraints (`n <= 1000`). With 1000 cards, `2^1000` is astronomically large. This solution would only work for very small inputs and is included to illustrate why the set-based approach is necessary.
|
||||
334
backend/data/questions/cat-and-mouse-ii.yaml
Normal file
334
backend/data/questions/cat-and-mouse-ii.yaml
Normal file
@@ -0,0 +1,334 @@
|
||||
title: Cat and Mouse II
|
||||
slug: cat-and-mouse-ii
|
||||
difficulty: hard
|
||||
leetcode_id: 1728
|
||||
leetcode_url: https://leetcode.com/problems/cat-and-mouse-ii/
|
||||
categories:
|
||||
- graphs
|
||||
- dynamic-programming
|
||||
patterns:
|
||||
- bfs
|
||||
- dynamic-programming
|
||||
- matrix-traversal
|
||||
|
||||
description: |
|
||||
A game is played by a cat and a mouse named Cat and Mouse.
|
||||
|
||||
The environment is represented by a `grid` of size `rows x cols`, where each element is a wall, floor, player (Cat, Mouse), or food.
|
||||
|
||||
- Players are represented by the characters `'C'` (Cat) and `'M'` (Mouse)
|
||||
- Floors are represented by the character `'.'` and can be walked on
|
||||
- Walls are represented by the character `'#'` and cannot be walked on
|
||||
- Food is represented by the character `'F'` and can be walked on
|
||||
- There is only one of each character `'C'`, `'M'`, and `'F'` in `grid`
|
||||
|
||||
Mouse and Cat play according to the following rules:
|
||||
|
||||
- Mouse **moves first**, then they take turns to move
|
||||
- During each turn, Cat and Mouse can jump in one of the four directions (left, right, up, down). They cannot jump over the wall nor outside of the `grid`
|
||||
- `catJump` and `mouseJump` are the maximum lengths Cat and Mouse can jump at a time, respectively. Cat and Mouse can jump less than the maximum length
|
||||
- Staying in the same position is allowed
|
||||
- Mouse can jump over Cat
|
||||
|
||||
The game can end in 4 ways:
|
||||
|
||||
- If Cat occupies the same position as Mouse, Cat wins
|
||||
- If Cat reaches the food first, Cat wins
|
||||
- If Mouse reaches the food first, Mouse wins
|
||||
- If Mouse cannot get to the food within 1000 turns, Cat wins
|
||||
|
||||
Given a `rows x cols` matrix `grid` and two integers `catJump` and `mouseJump`, return `true` *if Mouse can win the game if both Cat and Mouse play optimally*, otherwise return `false`.
|
||||
|
||||
constraints: |
|
||||
- `rows == grid.length`
|
||||
- `cols == grid[i].length`
|
||||
- `1 <= rows, cols <= 8`
|
||||
- `grid[i][j]` consists only of characters `'C'`, `'M'`, `'F'`, `'.'`, and `'#'`
|
||||
- There is only one of each character `'C'`, `'M'`, and `'F'` in `grid`
|
||||
- `1 <= catJump, mouseJump <= 8`
|
||||
|
||||
examples:
|
||||
- input: 'grid = ["####F","#C...","M...."], catJump = 1, mouseJump = 2'
|
||||
output: "true"
|
||||
explanation: "Cat cannot catch Mouse on its turn nor can it get the food before Mouse."
|
||||
- input: 'grid = ["M.C...F"], catJump = 1, mouseJump = 4'
|
||||
output: "true"
|
||||
explanation: "Mouse can reach the food in one jump (distance 4) before Cat can intercept."
|
||||
- input: 'grid = ["M.C...F"], catJump = 1, mouseJump = 3'
|
||||
output: "false"
|
||||
explanation: "Mouse cannot reach the food quickly enough — Cat can intercept or reach the food first."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
This is a classic **game theory** problem where two players (Cat and Mouse) take turns making optimal decisions. Think of it like a chess endgame: at each position, we need to determine whether the current player has a **winning strategy** assuming both players play perfectly.
|
||||
|
||||
The key insight is that this game has a **finite state space**. A state is defined by:
|
||||
- Mouse's position (row, col)
|
||||
- Cat's position (row, col)
|
||||
- Whose turn it is (Mouse or Cat)
|
||||
- The turn number (to detect stalemates)
|
||||
|
||||
With a maximum grid of `8x8 = 64` positions for each player and up to 1000 turns, the state space is bounded. We can explore all possible game states and determine winners using **memoization**.
|
||||
|
||||
The mental model is **minimax**: Mouse tries to reach food while avoiding Cat, and Cat tries to either catch Mouse or reach food first. At Mouse's turn, if *any* move leads to a Mouse win, Mouse wins. At Cat's turn, if *any* move leads to a Cat win, Cat wins.
|
||||
|
||||
approach: |
|
||||
We solve this using **Game Theory with Memoization (Top-Down DP)**:
|
||||
|
||||
**Step 1: Parse the grid**
|
||||
|
||||
- Find the starting positions of Mouse (`M`), Cat (`C`), and Food (`F`)
|
||||
- Store grid dimensions and wall locations
|
||||
|
||||
|
||||
|
||||
**Step 2: Define the game state**
|
||||
|
||||
- `(mouse_row, mouse_col, cat_row, cat_col, turn)` where `turn` indicates whose move it is
|
||||
- The turn counter also serves as a termination condition (if turns exceed a threshold, Cat wins by default)
|
||||
|
||||
|
||||
|
||||
**Step 3: Generate valid moves**
|
||||
|
||||
- For each player, generate all possible positions they can jump to:
|
||||
- Stay in place (always valid)
|
||||
- Jump 1 to `maxJump` cells in each of the 4 directions
|
||||
- Stop generating moves in a direction if a wall is encountered (cannot jump over walls)
|
||||
- Cannot move outside the grid
|
||||
|
||||
|
||||
|
||||
**Step 4: Implement recursive minimax with memoization**
|
||||
|
||||
- **Base cases:**
|
||||
- Mouse at Food position → Mouse wins (`true`)
|
||||
- Cat at Food position → Cat wins (`false`)
|
||||
- Cat catches Mouse (same position) → Cat wins (`false`)
|
||||
- Turn limit exceeded → Cat wins (`false`)
|
||||
- **Recursive cases:**
|
||||
- If Mouse's turn: Mouse wins if **any** valid move leads to a winning state
|
||||
- If Cat's turn: Cat wins if **all** Mouse moves lead to Cat winning (equivalently, Cat wins if **any** move leads to Cat winning)
|
||||
|
||||
|
||||
|
||||
**Step 5: Return the result**
|
||||
|
||||
- Call the recursive function with initial positions and Mouse's turn
|
||||
- Return whether Mouse has a winning strategy from the starting state
|
||||
|
||||
common_pitfalls:
|
||||
- title: Not Handling the Turn Limit
|
||||
description: |
|
||||
The problem states Mouse must win within 1000 turns. Without this limit, the recursion could run forever in cycles where neither player can force a win.
|
||||
|
||||
However, using exactly 1000 as the limit with full state tracking is expensive. A common optimisation is to use a smaller bound based on the observation that if a winning path exists, it will be found within `2 × rows × cols` moves (each cell visited once per player).
|
||||
wrong_approach: "No turn limit or using exactly 1000 turns"
|
||||
correct_approach: "Use a reasonable upper bound like 2 × rows × cols × 2"
|
||||
|
||||
- title: Incorrect Move Generation
|
||||
description: |
|
||||
When generating jumps, you must stop in a direction when you hit a wall. You cannot "skip over" walls.
|
||||
|
||||
For example, if jumping right with `maxJump = 3` and there's a wall at distance 2, you can only jump to distances 0 or 1 — not 3.
|
||||
wrong_approach: "Generate all positions within maxJump ignoring intermediate walls"
|
||||
correct_approach: "Stop generating moves in a direction when a wall is encountered"
|
||||
|
||||
- title: Forgetting "Stay in Place" Move
|
||||
description: |
|
||||
Both Cat and Mouse can choose to stay in their current position. This is a valid move that must be included in the move generation.
|
||||
|
||||
Missing this can cause incorrect results when staying put is the optimal strategy (e.g., Mouse waiting at food, or strategic positioning).
|
||||
wrong_approach: "Only generate moves to different positions"
|
||||
correct_approach: "Include current position as a valid move option"
|
||||
|
||||
- title: State Space Explosion
|
||||
description: |
|
||||
With naive implementation, the state space can be huge: `64 × 64 × 2 × 1000 = 8,192,000` states. This can cause TLE or MLE.
|
||||
|
||||
The key is recognising that if the game hasn't ended in `O(rows × cols)` turns, it likely won't end (it's cycling). Using a tighter turn bound significantly reduces states.
|
||||
wrong_approach: "Full 1000-turn state tracking"
|
||||
correct_approach: "Use bounded turns (e.g., 128 or rows × cols × 2) with memoization"
|
||||
|
||||
key_takeaways:
|
||||
- "**Game theory minimax**: One player wins if ANY of their moves leads to winning; the opponent wins if ALL moves lead to the opponent winning"
|
||||
- "**State-based memoization**: Define complete game states and cache results to avoid recomputation"
|
||||
- "**Bounded search**: Use domain knowledge to limit search depth (turn limits based on grid size, not arbitrary large numbers)"
|
||||
- "**Move generation matters**: Correctly handling walls, boundaries, and the 'stay' option is crucial for correctness"
|
||||
|
||||
time_complexity: "O(m × n × m × n × T × (catJump + mouseJump)) where `m × n` is the grid size and `T` is the turn limit. Each state is visited once, and from each state we explore up to `4 × (catJump + mouseJump)` moves."
|
||||
space_complexity: "O(m × n × m × n × T) for the memoization cache storing all visited states, plus O(T) recursion stack depth."
|
||||
|
||||
solutions:
|
||||
- approach_name: Memoized DFS (Minimax)
|
||||
is_optimal: true
|
||||
code: |
|
||||
def can_mouse_win(grid: list[str], cat_jump: int, mouse_jump: int) -> bool:
|
||||
rows, cols = len(grid), len(grid[0])
|
||||
|
||||
# Find starting positions
|
||||
mouse_start = cat_start = food_pos = None
|
||||
for r in range(rows):
|
||||
for c in range(cols):
|
||||
if grid[r][c] == 'M':
|
||||
mouse_start = (r, c)
|
||||
elif grid[r][c] == 'C':
|
||||
cat_start = (r, c)
|
||||
elif grid[r][c] == 'F':
|
||||
food_pos = (r, c)
|
||||
|
||||
# Directions: up, down, left, right
|
||||
directions = [(-1, 0), (1, 0), (0, -1), (0, 1)]
|
||||
|
||||
def get_moves(pos: tuple[int, int], max_jump: int) -> list[tuple[int, int]]:
|
||||
"""Generate all valid moves from a position."""
|
||||
r, c = pos
|
||||
moves = [(r, c)] # Can stay in place
|
||||
|
||||
for dr, dc in directions:
|
||||
for jump in range(1, max_jump + 1):
|
||||
nr, nc = r + dr * jump, c + dc * jump
|
||||
# Check bounds
|
||||
if not (0 <= nr < rows and 0 <= nc < cols):
|
||||
break
|
||||
# Check for wall - cannot jump over walls
|
||||
if grid[nr][nc] == '#':
|
||||
break
|
||||
moves.append((nr, nc))
|
||||
|
||||
return moves
|
||||
|
||||
# Memoization cache: (mouse_pos, cat_pos, turn, is_mouse_turn) -> mouse_wins
|
||||
memo = {}
|
||||
# Turn limit to prevent infinite loops (optimised bound)
|
||||
max_turns = rows * cols * 2
|
||||
|
||||
def dfs(mouse: tuple[int, int], cat: tuple[int, int],
|
||||
turn: int, is_mouse_turn: bool) -> bool:
|
||||
"""Returns True if Mouse can win from this state."""
|
||||
# Base cases
|
||||
if mouse == food_pos:
|
||||
return True # Mouse wins
|
||||
if cat == food_pos or cat == mouse:
|
||||
return False # Cat wins
|
||||
if turn >= max_turns:
|
||||
return False # Cat wins by timeout
|
||||
|
||||
state = (mouse, cat, turn, is_mouse_turn)
|
||||
if state in memo:
|
||||
return memo[state]
|
||||
|
||||
if is_mouse_turn:
|
||||
# Mouse wins if ANY move leads to a win
|
||||
result = False
|
||||
for next_mouse in get_moves(mouse, mouse_jump):
|
||||
if dfs(next_mouse, cat, turn + 1, False):
|
||||
result = True
|
||||
break
|
||||
else:
|
||||
# Cat wins if ANY move leads to Cat winning
|
||||
# So Mouse wins only if ALL cat moves still let Mouse win
|
||||
result = True
|
||||
for next_cat in get_moves(cat, cat_jump):
|
||||
if not dfs(mouse, next_cat, turn + 1, True):
|
||||
result = False
|
||||
break
|
||||
|
||||
memo[state] = result
|
||||
return result
|
||||
|
||||
return dfs(mouse_start, cat_start, 0, True)
|
||||
explanation: |
|
||||
**Time Complexity:** O(m² × n² × T × J) where `m × n` is grid size, `T` is turn limit, and `J` is average jump distance.
|
||||
|
||||
**Space Complexity:** O(m² × n² × T) for memoization cache.
|
||||
|
||||
This solution uses depth-first search with memoization to explore all possible game states. The minimax logic ensures optimal play: Mouse picks any winning move, Cat picks any move that prevents Mouse from winning. The turn limit prevents infinite loops in cyclic games.
|
||||
|
||||
- approach_name: BFS with State Graph
|
||||
is_optimal: false
|
||||
code: |
|
||||
from collections import deque
|
||||
|
||||
def can_mouse_win(grid: list[str], cat_jump: int, mouse_jump: int) -> bool:
|
||||
rows, cols = len(grid), len(grid[0])
|
||||
|
||||
# Find positions
|
||||
for r in range(rows):
|
||||
for c in range(cols):
|
||||
if grid[r][c] == 'M':
|
||||
mouse_start = (r, c)
|
||||
elif grid[r][c] == 'C':
|
||||
cat_start = (r, c)
|
||||
elif grid[r][c] == 'F':
|
||||
food_pos = (r, c)
|
||||
|
||||
directions = [(-1, 0), (1, 0), (0, -1), (0, 1)]
|
||||
|
||||
def get_moves(pos, max_jump):
|
||||
r, c = pos
|
||||
moves = [(r, c)]
|
||||
for dr, dc in directions:
|
||||
for jump in range(1, max_jump + 1):
|
||||
nr, nc = r + dr * jump, c + dc * jump
|
||||
if not (0 <= nr < rows and 0 <= nc < cols):
|
||||
break
|
||||
if grid[nr][nc] == '#':
|
||||
break
|
||||
moves.append((nr, nc))
|
||||
return moves
|
||||
|
||||
# Precompute moves for all positions
|
||||
mouse_moves = {}
|
||||
cat_moves = {}
|
||||
for r in range(rows):
|
||||
for c in range(cols):
|
||||
if grid[r][c] != '#':
|
||||
mouse_moves[(r, c)] = get_moves((r, c), mouse_jump)
|
||||
cat_moves[(r, c)] = get_moves((r, c), cat_jump)
|
||||
|
||||
# BFS approach: explore states level by level
|
||||
# State: (mouse_pos, cat_pos, is_mouse_turn)
|
||||
max_turns = rows * cols * 2
|
||||
|
||||
# Track visited states with turn number
|
||||
visited = set()
|
||||
queue = deque([(mouse_start, cat_start, True, 0)])
|
||||
visited.add((mouse_start, cat_start, True, 0))
|
||||
|
||||
while queue:
|
||||
mouse, cat, is_mouse_turn, turn = queue.popleft()
|
||||
|
||||
# Check win conditions
|
||||
if mouse == food_pos:
|
||||
return True
|
||||
if cat == food_pos or cat == mouse:
|
||||
continue # Cat wins this path, try others
|
||||
if turn >= max_turns:
|
||||
continue # Timeout, Cat wins
|
||||
|
||||
if is_mouse_turn:
|
||||
# Try all mouse moves
|
||||
for next_mouse in mouse_moves.get(mouse, []):
|
||||
state = (next_mouse, cat, False, turn + 1)
|
||||
if state not in visited:
|
||||
visited.add(state)
|
||||
# Quick win check
|
||||
if next_mouse == food_pos:
|
||||
return True
|
||||
queue.append(state)
|
||||
else:
|
||||
# Try all cat moves
|
||||
for next_cat in cat_moves.get(cat, []):
|
||||
state = (mouse, next_cat, True, turn + 1)
|
||||
if state not in visited:
|
||||
visited.add(state)
|
||||
queue.append(state)
|
||||
|
||||
return False
|
||||
explanation: |
|
||||
**Time Complexity:** O(m² × n² × T × J) — similar to DFS but explores level by level.
|
||||
|
||||
**Space Complexity:** O(m² × n² × T) for visited set and queue.
|
||||
|
||||
This BFS approach explores game states level by level (by turn number). While it can find solutions, the simple BFS doesn't correctly implement minimax logic — it finds *any* path where Mouse reaches food, not necessarily an optimal one. The memoized DFS approach above is more correct for game theory problems. This solution is included to illustrate an alternative exploration strategy, though it may not handle all edge cases correctly.
|
||||
296
backend/data/questions/cat-and-mouse.yaml
Normal file
296
backend/data/questions/cat-and-mouse.yaml
Normal file
@@ -0,0 +1,296 @@
|
||||
title: Cat and Mouse
|
||||
slug: cat-and-mouse
|
||||
difficulty: hard
|
||||
leetcode_id: 913
|
||||
leetcode_url: https://leetcode.com/problems/cat-and-mouse/
|
||||
categories:
|
||||
- graphs
|
||||
- dynamic-programming
|
||||
patterns:
|
||||
- dfs
|
||||
- dynamic-programming
|
||||
|
||||
description: |
|
||||
A game on an **undirected** graph is played by two players, Mouse and Cat, who alternate turns.
|
||||
|
||||
The graph is given as follows: `graph[a]` is a list of all nodes `b` such that `ab` is an edge of the graph.
|
||||
|
||||
The mouse starts at node `1` and goes first, the cat starts at node `2` and goes second, and there is a hole at node `0`.
|
||||
|
||||
During each player's turn, they **must** travel along one edge of the graph that meets where they are. For example, if the Mouse is at node `1`, it **must** travel to any node in `graph[1]`.
|
||||
|
||||
Additionally, it is not allowed for the Cat to travel to the Hole (node `0`).
|
||||
|
||||
Then, the game can end in three ways:
|
||||
|
||||
- If ever the Cat occupies the same node as the Mouse, the Cat wins.
|
||||
- If ever the Mouse reaches the Hole, the Mouse wins.
|
||||
- If ever a position is repeated (i.e., the players are in the same position as a previous turn, and it is the same player's turn to move), the game is a draw.
|
||||
|
||||
Given a `graph`, and assuming both players play optimally, return:
|
||||
- `1` if the mouse wins the game,
|
||||
- `2` if the cat wins the game, or
|
||||
- `0` if the game is a draw.
|
||||
|
||||
constraints: |
|
||||
- `3 <= graph.length <= 50`
|
||||
- `1 <= graph[i].length < graph.length`
|
||||
- `0 <= graph[i][j] < graph.length`
|
||||
- `graph[i][j] != i`
|
||||
- `graph[i]` is unique
|
||||
- The mouse and the cat can always move
|
||||
|
||||
examples:
|
||||
- input: "graph = [[2,5],[3],[0,4,5],[1,4,5],[2,3],[0,2,3]]"
|
||||
output: "0"
|
||||
explanation: "With optimal play from both sides, the game ends in a draw. Neither the mouse can guarantee reaching the hole, nor can the cat guarantee catching the mouse."
|
||||
- input: "graph = [[1,3],[0],[3],[0,2]]"
|
||||
output: "1"
|
||||
explanation: "The mouse can reach the hole (node 0) before the cat can catch it, so the mouse wins."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Imagine a chess match where both players can see every possible future move. This is a **two-player zero-sum game with perfect information** — what's good for the mouse is bad for the cat, and vice versa.
|
||||
|
||||
The key insight is that the game state can be fully described by three pieces of information: **where the mouse is**, **where the cat is**, and **whose turn it is**. From any state, we can determine the outcome if both players play optimally.
|
||||
|
||||
Think of it like this: the mouse is trying to reach the hole (node `0`) while avoiding the cat. The cat is trying to catch the mouse while being forbidden from entering the hole. Each player, on their turn, will choose the move that gives them the best possible outcome.
|
||||
|
||||
This is a classic application of **game theory** and **minimax reasoning**:
|
||||
- On the mouse's turn, it picks the move that maximises its chances of winning (or at least drawing)
|
||||
- On the cat's turn, it picks the move that maximises its chances of winning
|
||||
|
||||
We use **dynamic programming with memoisation** to avoid recalculating the same game states. The tricky part is handling draws — if we revisit a state, the game cycles forever.
|
||||
|
||||
approach: |
|
||||
We solve this using **Memoised DFS with Game State Tracking**:
|
||||
|
||||
**Step 1: Define the game state**
|
||||
|
||||
- `mouse`: Current position of the mouse (node index)
|
||||
- `cat`: Current position of the cat (node index)
|
||||
- `turn`: Whose turn it is (`0` for mouse, `1` for cat)
|
||||
|
||||
|
||||
|
||||
**Step 2: Identify base cases**
|
||||
|
||||
- If `mouse == 0`: Mouse wins (reached the hole) — return `1`
|
||||
- If `mouse == cat`: Cat wins (caught the mouse) — return `2`
|
||||
- If we've seen this state before or exceeded `2n` moves: Draw — return `0`
|
||||
|
||||
|
||||
|
||||
**Step 3: Apply minimax logic**
|
||||
|
||||
- **Mouse's turn**: The mouse tries each adjacent node and picks the best outcome
|
||||
- If any move leads to mouse winning, return `1`
|
||||
- If no winning move but a draw exists, return `0`
|
||||
- Otherwise, return `2` (cat wins)
|
||||
|
||||
- **Cat's turn**: The cat tries each adjacent node (except node `0`) and picks the best outcome
|
||||
- If any move leads to cat winning, return `2`
|
||||
- If no winning move but a draw exists, return `0`
|
||||
- Otherwise, return `1` (mouse wins)
|
||||
|
||||
|
||||
|
||||
**Step 4: Memoise results**
|
||||
|
||||
- Cache the result for each `(mouse, cat, turn)` state
|
||||
- This prevents exponential recalculation
|
||||
|
||||
|
||||
|
||||
The key to handling draws is limiting recursion depth to `2n` turns. If the game hasn't ended by then, it must be cycling through states, which means a draw.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Forgetting the Cat Cannot Enter the Hole
|
||||
description: |
|
||||
The cat is forbidden from moving to node `0` (the hole). When iterating through the cat's possible moves, you must skip node `0`.
|
||||
|
||||
Forgetting this constraint leads to incorrect results where the cat might "block" the hole.
|
||||
wrong_approach: "Allowing cat to move to any adjacent node"
|
||||
correct_approach: "Filter out node 0 from cat's valid moves"
|
||||
|
||||
- title: Infinite Recursion Without Cycle Detection
|
||||
description: |
|
||||
Without proper handling, the recursion can loop forever through repeating states. For example, mouse and cat might chase each other in circles.
|
||||
|
||||
The solution is to limit the recursion depth to `2n` moves (where `n` is the number of nodes). After this many moves without a winner, the game must be in a cycle, meaning it's a draw.
|
||||
wrong_approach: "Relying only on visited set without depth limit"
|
||||
correct_approach: "Limit recursion to 2n turns and return draw if exceeded"
|
||||
|
||||
- title: Incorrect Minimax Logic
|
||||
description: |
|
||||
A common mistake is not correctly implementing the minimax principle:
|
||||
- The mouse should return "mouse wins" if **any** move leads to a mouse win
|
||||
- The cat should return "cat wins" if **any** move leads to a cat win
|
||||
|
||||
Getting this backwards or not properly tracking "can draw" vs "must lose" leads to wrong answers.
|
||||
wrong_approach: "Returning immediately on first move result"
|
||||
correct_approach: "Evaluate all moves to find best possible outcome"
|
||||
|
||||
key_takeaways:
|
||||
- "**Game theory problems**: Use minimax — each player picks their best move assuming the opponent plays optimally"
|
||||
- "**State representation**: The state is `(mouse_pos, cat_pos, turn)` — memoising this avoids exponential blowup"
|
||||
- "**Cycle detection**: Limit recursion depth to detect draws; `2n` moves is sufficient since each state involves at most `n^2` configurations"
|
||||
- "**Graph games**: Many game problems on graphs use similar DFS + memoisation patterns"
|
||||
|
||||
time_complexity: "O(n^3). There are O(n^2) states (mouse position x cat position x turn), and each state explores O(n) neighbours."
|
||||
space_complexity: "O(n^2). The memoisation cache stores results for all `(mouse, cat, turn)` combinations."
|
||||
|
||||
solutions:
|
||||
- approach_name: Memoised DFS (Minimax)
|
||||
is_optimal: true
|
||||
code: |
|
||||
def cat_mouse_game(graph: list[list[int]]) -> int:
|
||||
n = len(graph)
|
||||
# Constants for game outcomes
|
||||
DRAW, MOUSE_WIN, CAT_WIN = 0, 1, 2
|
||||
|
||||
# Memoisation cache: (mouse, cat, turn) -> result
|
||||
memo = {}
|
||||
|
||||
def dfs(mouse: int, cat: int, turn: int, moves: int) -> int:
|
||||
# Base case: too many moves means a draw (cycle detected)
|
||||
if moves >= 2 * n:
|
||||
return DRAW
|
||||
|
||||
# Mouse reached the hole - mouse wins
|
||||
if mouse == 0:
|
||||
return MOUSE_WIN
|
||||
|
||||
# Cat caught the mouse - cat wins
|
||||
if mouse == cat:
|
||||
return CAT_WIN
|
||||
|
||||
# Check memo cache
|
||||
state = (mouse, cat, turn)
|
||||
if state in memo:
|
||||
return memo[state]
|
||||
|
||||
if turn == 0: # Mouse's turn
|
||||
# Mouse tries to win, or at least draw
|
||||
can_draw = False
|
||||
for next_mouse in graph[mouse]:
|
||||
result = dfs(next_mouse, cat, 1, moves + 1)
|
||||
if result == MOUSE_WIN:
|
||||
memo[state] = MOUSE_WIN
|
||||
return MOUSE_WIN
|
||||
if result == DRAW:
|
||||
can_draw = True
|
||||
# Mouse couldn't win; return draw if possible, else cat wins
|
||||
memo[state] = DRAW if can_draw else CAT_WIN
|
||||
|
||||
else: # Cat's turn
|
||||
# Cat tries to win, or at least draw
|
||||
can_draw = False
|
||||
for next_cat in graph[cat]:
|
||||
# Cat cannot move to the hole
|
||||
if next_cat == 0:
|
||||
continue
|
||||
result = dfs(mouse, next_cat, 0, moves + 1)
|
||||
if result == CAT_WIN:
|
||||
memo[state] = CAT_WIN
|
||||
return CAT_WIN
|
||||
if result == DRAW:
|
||||
can_draw = True
|
||||
# Cat couldn't win; return draw if possible, else mouse wins
|
||||
memo[state] = DRAW if can_draw else MOUSE_WIN
|
||||
|
||||
return memo[state]
|
||||
|
||||
# Start: mouse at 1, cat at 2, mouse's turn (0), 0 moves made
|
||||
return dfs(1, 2, 0, 0)
|
||||
explanation: |
|
||||
**Time Complexity:** O(n^3) — There are O(n^2) unique states, each exploring O(n) edges.
|
||||
|
||||
**Space Complexity:** O(n^2) — Memoisation cache for all states plus O(n) recursion depth.
|
||||
|
||||
This solution uses depth-first search with memoisation to explore the game tree. The minimax logic ensures each player picks their optimal move. The `moves` counter prevents infinite loops by detecting cycles after `2n` moves.
|
||||
|
||||
- approach_name: BFS with Coloring (Bottom-Up)
|
||||
is_optimal: true
|
||||
code: |
|
||||
from collections import deque
|
||||
|
||||
def cat_mouse_game(graph: list[list[int]]) -> int:
|
||||
n = len(graph)
|
||||
DRAW, MOUSE_WIN, CAT_WIN = 0, 1, 2
|
||||
|
||||
# color[mouse][cat][turn] = outcome
|
||||
color = [[[DRAW] * 2 for _ in range(n)] for _ in range(n)]
|
||||
# degree[mouse][cat][turn] = number of unprocessed children
|
||||
degree = [[[0] * 2 for _ in range(n)] for _ in range(n)]
|
||||
|
||||
# Initialise degrees (how many moves each player can make)
|
||||
for mouse in range(n):
|
||||
for cat in range(n):
|
||||
degree[mouse][cat][0] = len(graph[mouse]) # Mouse moves
|
||||
degree[mouse][cat][1] = len(graph[cat]) # Cat moves
|
||||
# Cat can't go to hole, so reduce cat's degree
|
||||
if 0 in graph[cat]:
|
||||
degree[mouse][cat][1] -= 1
|
||||
|
||||
# Queue of known states: (mouse, cat, turn, winner)
|
||||
queue = deque()
|
||||
|
||||
# Base cases: cat wins when cat == mouse (except at hole)
|
||||
for cat in range(1, n):
|
||||
for turn in range(2):
|
||||
color[cat][cat][turn] = CAT_WIN
|
||||
queue.append((cat, cat, turn, CAT_WIN))
|
||||
|
||||
# Base cases: mouse wins when mouse reaches hole
|
||||
for cat in range(1, n):
|
||||
for turn in range(2):
|
||||
color[0][cat][turn] = MOUSE_WIN
|
||||
queue.append((0, cat, turn, MOUSE_WIN))
|
||||
|
||||
# Process queue, propagating known results backwards
|
||||
while queue:
|
||||
mouse, cat, turn, result = queue.popleft()
|
||||
|
||||
# Find parent states that lead to this state
|
||||
if turn == 0: # Current is mouse's turn, parent was cat's turn
|
||||
for prev_cat in graph[cat]:
|
||||
if prev_cat == 0: # Cat can't be at hole
|
||||
continue
|
||||
if color[mouse][prev_cat][1] != DRAW:
|
||||
continue # Already determined
|
||||
if result == CAT_WIN:
|
||||
# Cat found a winning move
|
||||
color[mouse][prev_cat][1] = CAT_WIN
|
||||
queue.append((mouse, prev_cat, 1, CAT_WIN))
|
||||
else:
|
||||
# This child doesn't help cat; decrement degree
|
||||
degree[mouse][prev_cat][1] -= 1
|
||||
if degree[mouse][prev_cat][1] == 0:
|
||||
# All moves lead to mouse win/draw -> mouse wins
|
||||
color[mouse][prev_cat][1] = MOUSE_WIN
|
||||
queue.append((mouse, prev_cat, 1, MOUSE_WIN))
|
||||
else: # Current is cat's turn, parent was mouse's turn
|
||||
for prev_mouse in graph[mouse]:
|
||||
if color[prev_mouse][cat][0] != DRAW:
|
||||
continue # Already determined
|
||||
if result == MOUSE_WIN:
|
||||
# Mouse found a winning move
|
||||
color[prev_mouse][cat][0] = MOUSE_WIN
|
||||
queue.append((prev_mouse, cat, 0, MOUSE_WIN))
|
||||
else:
|
||||
# This child doesn't help mouse; decrement degree
|
||||
degree[prev_mouse][cat][0] -= 1
|
||||
if degree[prev_mouse][cat][0] == 0:
|
||||
# All moves lead to cat win/draw -> cat wins
|
||||
color[prev_mouse][cat][0] = CAT_WIN
|
||||
queue.append((prev_mouse, cat, 0, CAT_WIN))
|
||||
|
||||
# Return result for initial state: mouse at 1, cat at 2, mouse's turn
|
||||
return color[1][2][0]
|
||||
explanation: |
|
||||
**Time Complexity:** O(n^3) — We process each of the O(n^2) states at most once, with O(n) transitions.
|
||||
|
||||
**Space Complexity:** O(n^2) — Storage for the `color` and `degree` arrays.
|
||||
|
||||
This bottom-up approach works backwards from known terminal states. We start with states where we know the outcome (mouse at hole = mouse wins, cat catches mouse = cat wins) and propagate results backwards using the degree-counting technique. States that never get assigned a winner remain as draws.
|
||||
194
backend/data/questions/categorize-box-according-to-criteria.yaml
Normal file
194
backend/data/questions/categorize-box-according-to-criteria.yaml
Normal file
@@ -0,0 +1,194 @@
|
||||
title: Categorize Box According to Criteria
|
||||
slug: categorize-box-according-to-criteria
|
||||
difficulty: easy
|
||||
leetcode_id: 2525
|
||||
leetcode_url: https://leetcode.com/problems/categorize-box-according-to-criteria/
|
||||
categories:
|
||||
- math
|
||||
patterns:
|
||||
- greedy
|
||||
|
||||
description: |
|
||||
Given four integers `length`, `width`, `height`, and `mass`, representing the dimensions and mass of a box, respectively, return *a string representing the **category** of the box*.
|
||||
|
||||
The box is `"Bulky"` if:
|
||||
- **Any** of the dimensions of the box is greater or equal to `10^4`.
|
||||
- Or, the **volume** of the box is greater or equal to `10^9`.
|
||||
|
||||
If the mass of the box is greater or equal to `100`, it is `"Heavy"`.
|
||||
|
||||
If the box is both `"Bulky"` and `"Heavy"`, then its category is `"Both"`.
|
||||
|
||||
If the box is neither `"Bulky"` nor `"Heavy"`, then its category is `"Neither"`.
|
||||
|
||||
If the box is `"Bulky"` but not `"Heavy"`, then its category is `"Bulky"`.
|
||||
|
||||
If the box is `"Heavy"` but not `"Bulky"`, then its category is `"Heavy"`.
|
||||
|
||||
**Note** that the volume of the box is the product of its length, width, and height.
|
||||
|
||||
constraints: |
|
||||
- `1 <= length, width, height <= 10^5`
|
||||
- `1 <= mass <= 10^3`
|
||||
|
||||
examples:
|
||||
- input: "length = 1000, width = 35, height = 700, mass = 300"
|
||||
output: '"Heavy"'
|
||||
explanation: "None of the dimensions is >= 10^4. Volume = 24,500,000 < 10^9, so not Bulky. But mass = 300 >= 100, so Heavy. Since not Bulky but Heavy, return \"Heavy\"."
|
||||
- input: "length = 200, width = 50, height = 800, mass = 50"
|
||||
output: '"Neither"'
|
||||
explanation: "None of the dimensions is >= 10^4. Volume = 8,000,000 < 10^9, so not Bulky. Mass = 50 < 100, so not Heavy. Since neither Bulky nor Heavy, return \"Neither\"."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Think of this problem like a sorting machine at a shipping warehouse. Packages arrive on a conveyor belt, and the machine needs to route them to different areas based on two independent checks:
|
||||
|
||||
1. **Size check**: Is the box physically large enough to be considered "Bulky"?
|
||||
2. **Weight check**: Is the box heavy enough to be considered "Heavy"?
|
||||
|
||||
These two properties are completely independent of each other. A tiny box of lead could be Heavy but not Bulky. A massive box of feathers could be Bulky but not Heavy. A giant crate of iron could be Both. An ordinary shoebox could be Neither.
|
||||
|
||||
The key insight is that we don't need any complex logic — we simply evaluate both conditions separately, then combine the results to determine which of the four categories applies.
|
||||
|
||||
approach: |
|
||||
We solve this using a **Direct Condition Evaluation** approach:
|
||||
|
||||
**Step 1: Define the threshold constants**
|
||||
|
||||
- `DIMENSION_THRESHOLD`: `10^4` (10,000) — any single dimension at or above this makes the box Bulky
|
||||
- `VOLUME_THRESHOLD`: `10^9` (1,000,000,000) — volume at or above this makes the box Bulky
|
||||
- `MASS_THRESHOLD`: `100` — mass at or above this makes the box Heavy
|
||||
|
||||
|
||||
|
||||
**Step 2: Check if the box is Bulky**
|
||||
|
||||
- Calculate the volume: `length * width * height`
|
||||
- Check if any dimension >= `10^4` OR if volume >= `10^9`
|
||||
- Store result in a boolean `is_bulky`
|
||||
|
||||
|
||||
|
||||
**Step 3: Check if the box is Heavy**
|
||||
|
||||
- Check if mass >= `100`
|
||||
- Store result in a boolean `is_heavy`
|
||||
|
||||
|
||||
|
||||
**Step 4: Return the appropriate category**
|
||||
|
||||
- If both `is_bulky` and `is_heavy`: return `"Both"`
|
||||
- If `is_bulky` but not `is_heavy`: return `"Bulky"`
|
||||
- If `is_heavy` but not `is_bulky`: return `"Heavy"`
|
||||
- If neither: return `"Neither"`
|
||||
|
||||
common_pitfalls:
|
||||
- title: Integer Overflow When Calculating Volume
|
||||
description: |
|
||||
The volume calculation `length * width * height` can produce very large numbers. With maximum dimensions of `10^5` each, the maximum volume is `10^15`.
|
||||
|
||||
In languages with fixed-size integers (like 32-bit int in C++/Java), this will overflow since `2^31 - 1 ≈ 2.1 * 10^9`. Python handles arbitrary precision integers automatically, but in other languages you must use 64-bit integers (long) or check dimensions first before calculating volume.
|
||||
wrong_approach: "Using 32-bit integers for volume calculation"
|
||||
correct_approach: "Use 64-bit integers or check dimensions before volume"
|
||||
|
||||
- title: Misreading the Thresholds
|
||||
description: |
|
||||
The problem uses scientific notation: `10^4` for dimensions and `10^9` for volume. It's easy to misread these or add/remove zeros.
|
||||
|
||||
- Dimension threshold: 10,000 (ten thousand)
|
||||
- Volume threshold: 1,000,000,000 (one billion)
|
||||
|
||||
Double-check your constants match these values exactly.
|
||||
wrong_approach: "Using 1000 instead of 10000 for dimension threshold"
|
||||
correct_approach: "Carefully verify thresholds: 10^4 = 10000, 10^9 = 1000000000"
|
||||
|
||||
- title: Overcomplicating the Logic
|
||||
description: |
|
||||
With four possible outputs, it's tempting to write complex nested if-else chains. But since `is_bulky` and `is_heavy` are independent booleans, the logic is actually a simple 2x2 truth table.
|
||||
|
||||
You can even simplify using string arrays or tuple lookups, but straightforward if-else works fine for this easy problem.
|
||||
wrong_approach: "Deeply nested conditionals checking all cases redundantly"
|
||||
correct_approach: "Evaluate both conditions independently, then combine"
|
||||
|
||||
key_takeaways:
|
||||
- "**Decompose into independent checks**: When multiple conditions contribute to a result, evaluate each separately before combining"
|
||||
- "**Watch for overflow**: Volume and area calculations with large inputs can exceed 32-bit integer limits"
|
||||
- "**Truth table thinking**: With two boolean conditions and four outcomes, visualise as a 2x2 grid to ensure you cover all cases"
|
||||
- "**Constants over magic numbers**: Define thresholds as named constants for clarity and maintainability"
|
||||
|
||||
time_complexity: "O(1). We perform a fixed number of comparisons and one multiplication, regardless of input values."
|
||||
space_complexity: "O(1). We only use a few boolean variables and store no data proportional to input."
|
||||
|
||||
solutions:
|
||||
- approach_name: Direct Condition Evaluation
|
||||
is_optimal: true
|
||||
code: |
|
||||
def categorize_box(length: int, width: int, height: int, mass: int) -> str:
|
||||
# Define thresholds for classification
|
||||
DIMENSION_THRESHOLD = 10**4 # 10,000
|
||||
VOLUME_THRESHOLD = 10**9 # 1,000,000,000
|
||||
MASS_THRESHOLD = 100
|
||||
|
||||
# Calculate volume (Python handles large integers natively)
|
||||
volume = length * width * height
|
||||
|
||||
# Check if box is Bulky: any dimension >= 10^4 OR volume >= 10^9
|
||||
is_bulky = (
|
||||
length >= DIMENSION_THRESHOLD or
|
||||
width >= DIMENSION_THRESHOLD or
|
||||
height >= DIMENSION_THRESHOLD or
|
||||
volume >= VOLUME_THRESHOLD
|
||||
)
|
||||
|
||||
# Check if box is Heavy: mass >= 100
|
||||
is_heavy = mass >= MASS_THRESHOLD
|
||||
|
||||
# Return category based on combination of conditions
|
||||
if is_bulky and is_heavy:
|
||||
return "Both"
|
||||
elif is_bulky:
|
||||
return "Bulky"
|
||||
elif is_heavy:
|
||||
return "Heavy"
|
||||
else:
|
||||
return "Neither"
|
||||
explanation: |
|
||||
**Time Complexity:** O(1) — Fixed number of operations regardless of input size.
|
||||
|
||||
**Space Complexity:** O(1) — Only boolean variables and constants used.
|
||||
|
||||
We evaluate the Bulky and Heavy conditions independently, then use simple conditionals to return the correct category. This approach is clean, readable, and efficient.
|
||||
|
||||
- approach_name: Tuple Lookup
|
||||
is_optimal: false
|
||||
code: |
|
||||
def categorize_box(length: int, width: int, height: int, mass: int) -> str:
|
||||
# Calculate volume
|
||||
volume = length * width * height
|
||||
|
||||
# Determine boolean flags
|
||||
is_bulky = (
|
||||
length >= 10000 or
|
||||
width >= 10000 or
|
||||
height >= 10000 or
|
||||
volume >= 10**9
|
||||
)
|
||||
is_heavy = mass >= 100
|
||||
|
||||
# Use tuple as key to lookup result
|
||||
# (is_bulky, is_heavy) -> category
|
||||
categories = {
|
||||
(True, True): "Both",
|
||||
(True, False): "Bulky",
|
||||
(False, True): "Heavy",
|
||||
(False, False): "Neither"
|
||||
}
|
||||
|
||||
return categories[(is_bulky, is_heavy)]
|
||||
explanation: |
|
||||
**Time Complexity:** O(1) — Dictionary lookup is constant time.
|
||||
|
||||
**Space Complexity:** O(1) — Fixed-size dictionary with 4 entries.
|
||||
|
||||
This approach uses a dictionary with tuple keys to map all four combinations directly to their results. While slightly more Pythonic and eliminates if-else chains, it's marginally less efficient due to dictionary lookup overhead. For this simple problem, the direct approach is preferred.
|
||||
154
backend/data/questions/cells-in-a-range-on-an-excel-sheet.yaml
Normal file
154
backend/data/questions/cells-in-a-range-on-an-excel-sheet.yaml
Normal file
@@ -0,0 +1,154 @@
|
||||
title: Cells in a Range on an Excel Sheet
|
||||
slug: cells-in-a-range-on-an-excel-sheet
|
||||
difficulty: easy
|
||||
leetcode_id: 2194
|
||||
leetcode_url: https://leetcode.com/problems/cells-in-a-range-on-an-excel-sheet/
|
||||
categories:
|
||||
- strings
|
||||
- arrays
|
||||
patterns:
|
||||
- matrix-traversal
|
||||
|
||||
description: |
|
||||
A cell `(r, c)` of an Excel sheet is represented as a string `"<col><row>"` where:
|
||||
|
||||
- `<col>` denotes the column number `c` of the cell. It is represented by **alphabetical letters**. For example, the 1<sup>st</sup> column is denoted by `'A'`, the 2<sup>nd</sup> by `'B'`, the 3<sup>rd</sup> by `'C'`, and so on.
|
||||
- `<row>` is the row number `r` of the cell. The r<sup>th</sup> row is represented by the **integer** `r`.
|
||||
|
||||
You are given a string `s` in the format `"<col1><row1>:<col2><row2>"`, where `<col1>` represents the column `c1`, `<row1>` represents the row `r1`, `<col2>` represents the column `c2`, and `<row2>` represents the row `r2`, such that `r1 <= r2` and `c1 <= c2`.
|
||||
|
||||
Return *the **list of cells*** `(x, y)` *such that* `r1 <= x <= r2` *and* `c1 <= y <= c2`. The cells should be represented as **strings** in the format mentioned above and be sorted in **non-decreasing** order first by columns and then by rows.
|
||||
|
||||
constraints: |
|
||||
- `s.length == 5`
|
||||
- `'A' <= s[0] <= s[3] <= 'Z'`
|
||||
- `'1' <= s[1] <= s[4] <= '9'`
|
||||
- `s` consists of uppercase English letters, digits and `':'`
|
||||
|
||||
examples:
|
||||
- input: 's = "K1:L2"'
|
||||
output: '["K1","K2","L1","L2"]'
|
||||
explanation: "The cells K1, K2, L1, L2 form the rectangular range. The output is sorted by column first (K before L), then by row within each column."
|
||||
- input: 's = "A1:F1"'
|
||||
output: '["A1","B1","C1","D1","E1","F1"]'
|
||||
explanation: "All cells are in row 1, spanning columns A through F. Since there's only one row, the cells are simply ordered by column."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Think of an Excel spreadsheet as a grid where columns are labelled with letters (A, B, C, ...) and rows with numbers (1, 2, 3, ...).
|
||||
|
||||
When given a range like `"K1:L2"`, you're essentially asked to list all the cells in a rectangular region — starting from the top-left corner (K1) and ending at the bottom-right corner (L2).
|
||||
|
||||
The key insight is that this is a **nested iteration** problem. The sorting requirement — first by columns, then by rows — tells us exactly how to iterate: the **outer loop** goes through columns (letters), and the **inner loop** goes through rows (numbers).
|
||||
|
||||
Since columns are single uppercase letters in this problem, we can treat them as characters and iterate through their ASCII values. Similarly, rows are single digits, making extraction straightforward.
|
||||
|
||||
approach: |
|
||||
We solve this using **Nested Iteration** over columns and rows:
|
||||
|
||||
**Step 1: Parse the input string**
|
||||
|
||||
- `s[0]`: Starting column (character)
|
||||
- `s[1]`: Starting row (character representing a digit)
|
||||
- `s[3]`: Ending column (character)
|
||||
- `s[4]`: Ending row (character representing a digit)
|
||||
|
||||
|
||||
|
||||
**Step 2: Iterate through columns (outer loop)**
|
||||
|
||||
- Loop from `s[0]` to `s[3]` inclusive
|
||||
- Use `chr()` and `ord()` to iterate through character codes
|
||||
- This ensures columns are processed in alphabetical order
|
||||
|
||||
|
||||
|
||||
**Step 3: Iterate through rows (inner loop)**
|
||||
|
||||
- For each column, loop from `s[1]` to `s[4]` inclusive
|
||||
- Convert row characters to integers for range iteration
|
||||
- Combine the column letter and row number to form the cell string
|
||||
|
||||
|
||||
|
||||
**Step 4: Return the result**
|
||||
|
||||
- The nested loop order naturally produces the required sorting (columns first, then rows)
|
||||
|
||||
common_pitfalls:
|
||||
- title: Wrong Loop Order
|
||||
description: |
|
||||
A common mistake is iterating rows in the outer loop and columns in the inner loop.
|
||||
|
||||
This would produce cells sorted by row first: `["K1", "L1", "K2", "L2"]` instead of the required `["K1", "K2", "L1", "L2"]`.
|
||||
|
||||
Always read the sorting requirement carefully — "sorted first by columns and then by rows" means columns are the primary sort key.
|
||||
wrong_approach: "for row in rows: for col in cols"
|
||||
correct_approach: "for col in cols: for row in rows"
|
||||
|
||||
- title: Off-by-One in Range
|
||||
description: |
|
||||
When using `range()` in Python, the end value is exclusive. If iterating from column `'K'` to `'L'`, using `range(ord('K'), ord('L'))` would miss `'L'`.
|
||||
|
||||
Always add 1 to the end value: `range(ord(start_col), ord(end_col) + 1)`.
|
||||
wrong_approach: "range(ord(s[0]), ord(s[3]))"
|
||||
correct_approach: "range(ord(s[0]), ord(s[3]) + 1)"
|
||||
|
||||
- title: Forgetting String Conversion
|
||||
description: |
|
||||
When building cell strings, remember that iterating with `range()` over rows gives integers, but column iteration with `chr(ord(...))` gives characters.
|
||||
|
||||
Ensure both parts are converted to strings when concatenating: `col + str(row)` or using an f-string.
|
||||
|
||||
key_takeaways:
|
||||
- "**Nested iteration order matters**: The loop structure directly determines the output order — outer loop = primary sort key"
|
||||
- "**Character arithmetic**: Use `ord()` and `chr()` to iterate through letter ranges, treating characters as their ASCII codes"
|
||||
- "**Fixed-format parsing**: When input has a guaranteed format (like `s.length == 5`), direct indexing is simpler than regex or split operations"
|
||||
- "**Excel-style coordinates**: A common pattern in grid problems — understand the column-letter, row-number convention"
|
||||
|
||||
time_complexity: "O(m * n) where m is the number of columns and n is the number of rows in the range. We visit each cell exactly once."
|
||||
space_complexity: "O(m * n) to store the result list containing all cells in the range."
|
||||
|
||||
solutions:
|
||||
- approach_name: Nested Iteration
|
||||
is_optimal: true
|
||||
code: |
|
||||
def cells_in_range(s: str) -> list[str]:
|
||||
result = []
|
||||
|
||||
# Extract start and end coordinates from fixed-format string
|
||||
start_col, start_row = s[0], s[1]
|
||||
end_col, end_row = s[3], s[4]
|
||||
|
||||
# Outer loop: iterate through columns (letters)
|
||||
for col in range(ord(start_col), ord(end_col) + 1):
|
||||
# Inner loop: iterate through rows (numbers)
|
||||
for row in range(int(start_row), int(end_row) + 1):
|
||||
# Combine column letter and row number
|
||||
result.append(chr(col) + str(row))
|
||||
|
||||
return result
|
||||
explanation: |
|
||||
**Time Complexity:** O(m * n) — We iterate through each cell in the rectangular range exactly once.
|
||||
|
||||
**Space Complexity:** O(m * n) — The result list stores all cells in the range.
|
||||
|
||||
The nested loop structure naturally produces the required sorting order. By iterating columns in the outer loop, we ensure cells are grouped by column first, then ordered by row within each column.
|
||||
|
||||
- approach_name: List Comprehension
|
||||
is_optimal: true
|
||||
code: |
|
||||
def cells_in_range(s: str) -> list[str]:
|
||||
# Pythonic one-liner using nested list comprehension
|
||||
# Outer comprehension: columns, Inner: rows
|
||||
return [
|
||||
chr(col) + str(row)
|
||||
for col in range(ord(s[0]), ord(s[3]) + 1)
|
||||
for row in range(int(s[1]), int(s[4]) + 1)
|
||||
]
|
||||
explanation: |
|
||||
**Time Complexity:** O(m * n) — Same as the iterative approach.
|
||||
|
||||
**Space Complexity:** O(m * n) — The result list stores all cells.
|
||||
|
||||
This is the same algorithm expressed as a list comprehension. The order of the `for` clauses matches the nested loop structure — columns first (outer), then rows (inner). This produces identical output in a more concise form.
|
||||
168
backend/data/questions/cells-with-odd-values-in-a-matrix.yaml
Normal file
168
backend/data/questions/cells-with-odd-values-in-a-matrix.yaml
Normal file
@@ -0,0 +1,168 @@
|
||||
title: Cells with Odd Values in a Matrix
|
||||
slug: cells-with-odd-values-in-a-matrix
|
||||
difficulty: easy
|
||||
leetcode_id: 1252
|
||||
leetcode_url: https://leetcode.com/problems/cells-with-odd-values-in-a-matrix/
|
||||
categories:
|
||||
- arrays
|
||||
- math
|
||||
patterns:
|
||||
- prefix-sum
|
||||
|
||||
description: |
|
||||
There is an `m x n` matrix that is initialised to all `0`'s. There is also a 2D array `indices` where each `indices[i] = [r_i, c_i]` represents a **0-indexed location** to perform some increment operations on the matrix.
|
||||
|
||||
For each location `indices[i]`, do **both** of the following:
|
||||
|
||||
1. Increment **all** the cells on row `r_i`.
|
||||
2. Increment **all** the cells on column `c_i`.
|
||||
|
||||
Given `m`, `n`, and `indices`, return *the **number of odd-valued cells** in the matrix after applying the increment to all locations in* `indices`.
|
||||
|
||||
constraints: |
|
||||
- `1 <= m, n <= 50`
|
||||
- `1 <= indices.length <= 100`
|
||||
- `0 <= r_i < m`
|
||||
- `0 <= c_i < n`
|
||||
|
||||
examples:
|
||||
- input: "m = 2, n = 3, indices = [[0,1],[1,1]]"
|
||||
output: "6"
|
||||
explanation: "Initial matrix = [[0,0,0],[0,0,0]]. After applying the first increment it becomes [[1,2,1],[0,1,0]]. The final matrix is [[1,3,1],[1,3,1]], which contains 6 odd numbers."
|
||||
- input: "m = 2, n = 2, indices = [[1,1],[0,0]]"
|
||||
output: "0"
|
||||
explanation: "Final matrix = [[2,2],[2,2]]. There are no odd numbers in the final matrix."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
At first glance, this problem seems to require simulating the entire matrix and performing all the increments. However, there's a clever mathematical insight that lets us avoid building the matrix entirely.
|
||||
|
||||
Think of it like this: each cell `(i, j)` in the matrix gets incremented once for every time row `i` appears in `indices`, and once for every time column `j` appears in `indices`. The final value at cell `(i, j)` is simply `row_count[i] + col_count[j]`.
|
||||
|
||||
Now here's the key insight: **a number is odd if and only if it's the sum of an odd and an even number**. If both counts are odd or both are even, their sum is even. If one is odd and one is even, their sum is odd.
|
||||
|
||||
So instead of tracking actual cell values, we only need to count:
|
||||
- How many rows have an **odd** increment count
|
||||
- How many columns have an **odd** increment count
|
||||
|
||||
A cell is odd exactly when its row has an odd count XOR its column has an odd count. The total count of odd cells is: `(odd_rows × even_cols) + (even_rows × odd_cols)`.
|
||||
|
||||
approach: |
|
||||
We can solve this efficiently by counting row and column increments:
|
||||
|
||||
**Step 1: Count increments per row and column**
|
||||
|
||||
- Create arrays `row_count[m]` and `col_count[n]`, initialised to `0`
|
||||
- For each `[r, c]` in `indices`, increment `row_count[r]` and `col_count[c]`
|
||||
|
||||
|
||||
|
||||
**Step 2: Count rows and columns with odd increment counts**
|
||||
|
||||
- `odd_rows`: Count of rows where `row_count[i] % 2 == 1`
|
||||
- `odd_cols`: Count of columns where `col_count[j] % 2 == 1`
|
||||
|
||||
|
||||
|
||||
**Step 3: Calculate the answer using the XOR property**
|
||||
|
||||
- Cells with odd values occur when exactly one of (row count, column count) is odd
|
||||
- Formula: `odd_rows × (n - odd_cols) + (m - odd_rows) × odd_cols`
|
||||
- This equals `odd_rows × even_cols + even_rows × odd_cols`
|
||||
|
||||
|
||||
|
||||
**Step 4: Return the result**
|
||||
|
||||
- The formula gives us the exact count of odd-valued cells without building the matrix
|
||||
|
||||
common_pitfalls:
|
||||
- title: Building the Entire Matrix
|
||||
description: |
|
||||
A natural first approach is to create an `m × n` matrix and simulate all the increments. For each index pair, you'd iterate through an entire row (n cells) and an entire column (m cells).
|
||||
|
||||
This leads to **O(k × (m + n))** time where `k` is the number of indices, and **O(m × n)** space for the matrix. While this works for the given constraints, it's inefficient and misses the elegant mathematical solution.
|
||||
wrong_approach: "Simulate increments on actual matrix"
|
||||
correct_approach: "Count row/column increments and use parity formula"
|
||||
|
||||
- title: Forgetting the XOR Logic
|
||||
description: |
|
||||
When calculating odd cells, remember that `odd + odd = even` and `even + even = even`. Only `odd + even = odd`.
|
||||
|
||||
A common mistake is to multiply `odd_rows × odd_cols`, which counts cells where *both* are odd — those cells actually have even values!
|
||||
wrong_approach: "Count cells where both row and column have odd counts"
|
||||
correct_approach: "Count cells where exactly one of row or column has odd count"
|
||||
|
||||
- title: Off-by-One in Even Count Calculation
|
||||
description: |
|
||||
When computing `even_rows` and `even_cols`, make sure to use `m - odd_rows` and `n - odd_cols` respectively, not `m - 1` or similar.
|
||||
|
||||
The total rows minus odd rows gives even rows. Getting this wrong leads to incorrect final counts.
|
||||
|
||||
key_takeaways:
|
||||
- "**Parity insight**: A sum is odd if and only if exactly one addend is odd — this XOR property enables O(1) cell classification"
|
||||
- "**Avoid unnecessary simulation**: Count what matters (increments per row/col) rather than simulating every operation"
|
||||
- "**Space optimisation**: Instead of O(m × n) matrix, use O(m + n) arrays for row and column counts"
|
||||
- "**Mathematical reformulation**: Many simulation problems can be solved by finding the mathematical formula for the final state"
|
||||
|
||||
time_complexity: "O(k + m + n). We iterate through `indices` once (k operations), then through rows (m) and columns (n) to count odd values."
|
||||
space_complexity: "O(m + n). We use two arrays to track increment counts for each row and column."
|
||||
|
||||
solutions:
|
||||
- approach_name: Row and Column Counting
|
||||
is_optimal: true
|
||||
code: |
|
||||
def odd_cells(m: int, n: int, indices: list[list[int]]) -> int:
|
||||
# Track how many times each row/column is incremented
|
||||
row_count = [0] * m
|
||||
col_count = [0] * n
|
||||
|
||||
# Count increments from each index pair
|
||||
for r, c in indices:
|
||||
row_count[r] += 1
|
||||
col_count[c] += 1
|
||||
|
||||
# Count rows and columns with odd increment counts
|
||||
odd_rows = sum(1 for count in row_count if count % 2 == 1)
|
||||
odd_cols = sum(1 for count in col_count if count % 2 == 1)
|
||||
|
||||
# Cell is odd when exactly one of (row, col) has odd count
|
||||
# odd_rows * even_cols + even_rows * odd_cols
|
||||
return odd_rows * (n - odd_cols) + (m - odd_rows) * odd_cols
|
||||
explanation: |
|
||||
**Time Complexity:** O(k + m + n) — One pass through indices, one through rows, one through columns.
|
||||
|
||||
**Space Complexity:** O(m + n) — Two arrays for row and column counts.
|
||||
|
||||
We leverage the insight that a cell's final value equals its row's increment count plus its column's increment count. A cell is odd precisely when one count is odd and the other is even, which we compute using the XOR-like formula.
|
||||
|
||||
- approach_name: Simulation
|
||||
is_optimal: false
|
||||
code: |
|
||||
def odd_cells(m: int, n: int, indices: list[list[int]]) -> int:
|
||||
# Create the matrix initialised to zeros
|
||||
matrix = [[0] * n for _ in range(m)]
|
||||
|
||||
# Apply each increment operation
|
||||
for r, c in indices:
|
||||
# Increment entire row r
|
||||
for j in range(n):
|
||||
matrix[r][j] += 1
|
||||
# Increment entire column c
|
||||
for i in range(m):
|
||||
matrix[i][c] += 1
|
||||
|
||||
# Count odd values
|
||||
count = 0
|
||||
for row in matrix:
|
||||
for val in row:
|
||||
if val % 2 == 1:
|
||||
count += 1
|
||||
|
||||
return count
|
||||
explanation: |
|
||||
**Time Complexity:** O(k × (m + n) + m × n) — For each of k indices, we update m + n cells, then scan entire matrix.
|
||||
|
||||
**Space Complexity:** O(m × n) — Full matrix storage.
|
||||
|
||||
This brute force approach simulates the exact operations described. It's correct but inefficient — we're doing unnecessary work by tracking actual values when we only need parity information. Included to show the progression from naive to optimal solution.
|
||||
151
backend/data/questions/chalkboard-xor-game.yaml
Normal file
151
backend/data/questions/chalkboard-xor-game.yaml
Normal file
@@ -0,0 +1,151 @@
|
||||
title: Chalkboard XOR Game
|
||||
slug: chalkboard-xor-game
|
||||
difficulty: hard
|
||||
leetcode_id: 810
|
||||
leetcode_url: https://leetcode.com/problems/chalkboard-xor-game/
|
||||
categories:
|
||||
- arrays
|
||||
- math
|
||||
patterns:
|
||||
- greedy
|
||||
|
||||
description: |
|
||||
You are given an array of integers `nums` representing the numbers written on a chalkboard.
|
||||
|
||||
Alice and Bob take turns erasing exactly one number from the chalkboard, with Alice starting first. If erasing a number causes the bitwise XOR of all the elements of the chalkboard to become `0`, then that player loses. The bitwise XOR of one element is that element itself, and the bitwise XOR of no elements is `0`.
|
||||
|
||||
Also, if any player starts their turn with the bitwise XOR of all the elements of the chalkboard equal to `0`, then that player wins.
|
||||
|
||||
Return `true` *if and only if Alice wins the game, assuming both players play optimally*.
|
||||
|
||||
constraints: |
|
||||
- `1 <= nums.length <= 1000`
|
||||
- `0 <= nums[i] < 2^16`
|
||||
|
||||
examples:
|
||||
- input: "nums = [1,1,2]"
|
||||
output: "false"
|
||||
explanation: "Alice has two choices: erase 1 or erase 2. If she erases 1, nums becomes [1, 2] with XOR = 3. Bob can then force Alice to erase the last element and lose. If Alice erases 2, nums becomes [1, 1] with XOR = 0, so Alice loses immediately."
|
||||
- input: "nums = [0,1]"
|
||||
output: "true"
|
||||
explanation: "Alice can erase 1, leaving [0]. The XOR is 0, but it was already non-zero before her move completed, and now Bob faces XOR = 0 at the start of his turn, so Bob loses."
|
||||
- input: "nums = [1,2,3]"
|
||||
output: "true"
|
||||
explanation: "XOR of [1,2,3] = 0. Alice starts with XOR = 0, so she wins immediately."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
This problem looks like it requires game simulation or minimax, but there's a beautiful mathematical insight that reduces it to a simple formula.
|
||||
|
||||
Think about XOR properties: if the XOR of all elements is already `0` when it's your turn, you win. The key insight is understanding *when* Alice can be forced into a losing position.
|
||||
|
||||
Alice loses only if **every** number she could erase would make the XOR become `0`. Let's think about when this happens. If the current XOR is `X ≠ 0`, and Alice must pick a number `nums[i]` such that `X XOR nums[i] = 0`, then `nums[i] = X`. This means Alice loses only if **all** remaining numbers equal the current XOR value — which means all numbers are identical.
|
||||
|
||||
But here's the clever part: if all `n` numbers are identical (say, all equal to `v`), then:
|
||||
- If `n` is even: XOR = `v XOR v XOR ... = 0`, so Alice wins immediately
|
||||
- If `n` is odd: XOR = `v`, and Alice must erase `v`, leaving an even count where Bob faces XOR = 0 and wins
|
||||
|
||||
Generalising this: Alice loses only when forced to make XOR = 0. With an even number of elements and non-zero XOR, Alice can always find a "safe" move (at least one element whose removal doesn't make XOR = 0). This is because if removing **any** element made XOR = 0, all elements would need to equal the current XOR, making them all identical — but then with even count, XOR would already be 0.
|
||||
|
||||
Therefore: **Alice wins if and only if the initial XOR is 0, OR the array has an even length.**
|
||||
|
||||
approach: |
|
||||
The solution is remarkably simple once you understand the game theory:
|
||||
|
||||
**Step 1: Calculate the XOR of all elements**
|
||||
|
||||
- Compute `xor_sum` by XORing all elements in `nums`
|
||||
- This tells us the starting game state
|
||||
|
||||
|
||||
|
||||
**Step 2: Check the two winning conditions for Alice**
|
||||
|
||||
- **Condition 1:** If `xor_sum == 0`, Alice wins immediately (she starts with XOR = 0)
|
||||
- **Condition 2:** If `len(nums)` is even, Alice wins (she can always find a safe move)
|
||||
|
||||
|
||||
|
||||
**Step 3: Return the result**
|
||||
|
||||
- Return `True` if either condition is met, `False` otherwise
|
||||
- This covers all cases: Alice wins if she starts with XOR = 0, or if she has an even number of elements to work with
|
||||
|
||||
|
||||
|
||||
The mathematical proof: with even length and non-zero XOR, if removing any element made XOR = 0, then each element `nums[i]` would equal the total XOR. But `n` identical values XORed together (with `n` even) gives 0, contradicting our assumption that XOR ≠ 0.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Attempting Game Simulation
|
||||
description: |
|
||||
A natural first instinct is to simulate the game using recursion or dynamic programming, trying all possible moves for both players.
|
||||
|
||||
With up to 1000 elements, this approach has exponential complexity — the game tree branches at each turn, leading to `O(n!)` possibilities. This will cause **Time Limit Exceeded**.
|
||||
|
||||
The key is recognising this as a **mathematical problem** with a closed-form solution, not a search problem.
|
||||
wrong_approach: "Recursive simulation with memoisation"
|
||||
correct_approach: "Use XOR properties and parity analysis"
|
||||
|
||||
- title: Forgetting the Even-Length Guarantee
|
||||
description: |
|
||||
Some solutions correctly check if XOR = 0 but miss the even-length winning condition.
|
||||
|
||||
The insight is subtle: with an even number of elements and non-zero XOR, Alice can *always* find at least one element whose removal doesn't make XOR = 0. This is guaranteed by the pigeonhole principle applied to XOR values.
|
||||
wrong_approach: "Only checking if initial XOR is 0"
|
||||
correct_approach: "Check both XOR = 0 and even length conditions"
|
||||
|
||||
- title: Misunderstanding the Winning Condition
|
||||
description: |
|
||||
The rules have a subtle distinction:
|
||||
- If you **start** your turn with XOR = 0, you **win**
|
||||
- If your move **causes** XOR to become 0, you **lose**
|
||||
|
||||
These are different! Starting with XOR = 0 is an immediate win, not a loss. This catches players who think XOR = 0 is always bad.
|
||||
wrong_approach: "Thinking XOR = 0 means current player loses"
|
||||
correct_approach: "XOR = 0 at turn start means win; causing XOR = 0 means loss"
|
||||
|
||||
key_takeaways:
|
||||
- "**Game theory simplification**: Many two-player games have elegant mathematical solutions — look for invariants and parity arguments before attempting simulation"
|
||||
- "**XOR properties**: XOR of identical values cancels out (even count = 0), which drives the parity-based solution"
|
||||
- "**Constraint analysis**: The even-length guarantee comes from a proof by contradiction using XOR algebra"
|
||||
- "**Pattern recognition**: This problem teaches that 'hard' brainteasers often have O(n) or O(1) solutions hiding behind the complexity"
|
||||
|
||||
time_complexity: "O(n). We traverse the array once to compute the XOR of all elements."
|
||||
space_complexity: "O(1). We only use a single variable (`xor_sum`) regardless of input size."
|
||||
|
||||
solutions:
|
||||
- approach_name: XOR and Parity Check
|
||||
is_optimal: true
|
||||
code: |
|
||||
def xor_game(nums: list[int]) -> bool:
|
||||
# Calculate XOR of all elements
|
||||
xor_sum = 0
|
||||
for num in nums:
|
||||
xor_sum ^= num
|
||||
|
||||
# Alice wins if:
|
||||
# 1. XOR is already 0 (she wins immediately), OR
|
||||
# 2. Array has even length (she can always find a safe move)
|
||||
return xor_sum == 0 or len(nums) % 2 == 0
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Single pass to compute XOR.
|
||||
|
||||
**Space Complexity:** O(1) — Only one integer variable used.
|
||||
|
||||
The solution uses game theory insight: Alice wins if she starts with XOR = 0, or if she has an even number of elements (guaranteeing a safe move exists). This elegant formula avoids expensive game tree simulation.
|
||||
|
||||
- approach_name: One-Liner with Reduce
|
||||
is_optimal: true
|
||||
code: |
|
||||
from functools import reduce
|
||||
from operator import xor
|
||||
|
||||
def xor_game(nums: list[int]) -> bool:
|
||||
# XOR all elements; Alice wins if result is 0 or length is even
|
||||
return reduce(xor, nums) == 0 or len(nums) % 2 == 0
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Reduce iterates through all elements.
|
||||
|
||||
**Space Complexity:** O(1) — No additional space beyond the accumulator.
|
||||
|
||||
A more Pythonic version using `functools.reduce` with the XOR operator. Functionally identical to the explicit loop but more concise.
|
||||
174
backend/data/questions/champagne-tower.yaml
Normal file
174
backend/data/questions/champagne-tower.yaml
Normal file
@@ -0,0 +1,174 @@
|
||||
title: Champagne Tower
|
||||
slug: champagne-tower
|
||||
difficulty: medium
|
||||
leetcode_id: 799
|
||||
leetcode_url: https://leetcode.com/problems/champagne-tower/
|
||||
categories:
|
||||
- arrays
|
||||
- dynamic-programming
|
||||
patterns:
|
||||
- dynamic-programming
|
||||
|
||||
description: |
|
||||
We stack glasses in a pyramid, where the **first** row has `1` glass, the **second** row has `2` glasses, and so on until the 100<sup>th</sup> row. Each glass holds one cup of champagne.
|
||||
|
||||
Then, some champagne is poured into the first glass at the top. When the topmost glass is full, any excess liquid poured will fall equally to the glass immediately to the left and right of it. When those glasses become full, any excess champagne will fall equally to the left and right of those glasses, and so on. (A glass at the bottom row has its excess champagne fall on the floor.)
|
||||
|
||||
For example, after one cup of champagne is poured, the topmost glass is full. After two cups of champagne are poured, the two glasses on the second row are half full. After three cups of champagne are poured, those two cups become full — there are 3 full glasses total now. After four cups of champagne are poured, the third row has the middle glass half full, and the two outside glasses are a quarter full.
|
||||
|
||||
Now after pouring some non-negative integer cups of champagne, return how full the `j`<sup>th</sup> glass in the `i`<sup>th</sup> row is (both `i` and `j` are 0-indexed).
|
||||
|
||||
constraints: |
|
||||
- `0 <= poured <= 10^9`
|
||||
- `0 <= query_glass <= query_row < 100`
|
||||
|
||||
examples:
|
||||
- input: "poured = 1, query_row = 1, query_glass = 1"
|
||||
output: "0.00000"
|
||||
explanation: "We poured 1 cup of champagne to the top glass of the tower (which is indexed as (0, 0)). There will be no excess liquid so all the glasses under the top glass will remain empty."
|
||||
- input: "poured = 2, query_row = 1, query_glass = 1"
|
||||
output: "0.50000"
|
||||
explanation: "We poured 2 cups of champagne to the top glass of the tower (which is indexed as (0, 0)). There is one cup of excess liquid. The glass indexed as (1, 0) and the glass indexed as (1, 1) will share the excess liquid equally, and each will get half cup of champagne."
|
||||
- input: "poured = 100000009, query_row = 33, query_glass = 17"
|
||||
output: "1.00000"
|
||||
explanation: "With a large amount of champagne poured, the glass at row 33, position 17 is completely full."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Imagine pouring champagne into the top of an actual glass pyramid at a wedding. The liquid flows naturally — filling each glass until it overflows, then spilling equally to the two glasses below it.
|
||||
|
||||
The key insight is to **simulate this flow process** rather than trying to calculate the final state directly. Think of each glass as a container that can temporarily hold *any* amount of liquid (not just 1 cup). The amount in a glass represents the total champagne that has flowed into it. If this exceeds 1 cup, the glass keeps 1 cup and the excess spills equally to its two children below.
|
||||
|
||||
This is a classic **simulation with dynamic programming** approach. We process the pyramid row by row, top to bottom. At each glass, we calculate how much liquid overflows and distribute it to the next row. The "state" we track is how much total liquid has flowed into each glass.
|
||||
|
||||
Why does this work? Because champagne only flows downward. A glass in row `i` only receives liquid from row `i-1`, which we've already fully processed. This creates a natural order of computation — perfect for DP.
|
||||
|
||||
approach: |
|
||||
We solve this using **Row-by-Row Simulation**:
|
||||
|
||||
**Step 1: Initialise the DP table**
|
||||
|
||||
- Create a 2D array `dp` where `dp[i][j]` represents the total champagne that has flowed into the glass at row `i`, position `j`
|
||||
- Set `dp[0][0] = poured` — all champagne starts at the top glass
|
||||
|
||||
|
||||
|
||||
**Step 2: Process each row from top to bottom**
|
||||
|
||||
- For each glass at position `(i, j)`, check if it has more than 1 cup
|
||||
- If `dp[i][j] > 1`, calculate the overflow: `overflow = dp[i][j] - 1`
|
||||
- Distribute half the overflow to each child: `dp[i+1][j] += overflow / 2` and `dp[i+1][j+1] += overflow / 2`
|
||||
- The current glass keeps exactly 1 cup (it's full)
|
||||
|
||||
|
||||
|
||||
**Step 3: Return the answer**
|
||||
|
||||
- After processing all rows up to `query_row`, return `min(1, dp[query_row][query_glass])`
|
||||
- The `min(1, ...)` ensures we return at most 1.0 (a glass can't be more than 100% full)
|
||||
|
||||
|
||||
|
||||
**Space Optimisation:** Since each row only depends on the previous row, we can optimise to O(row) space by only keeping two rows at a time. However, the O(row²) solution is clearer and sufficient given the constraint that `query_row < 100`.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Trying to Calculate Directly with Combinatorics
|
||||
description: |
|
||||
A tempting approach is to calculate how much champagne reaches each glass using Pascal's Triangle-style combinatorics. But this fails because:
|
||||
|
||||
- Glasses have a **capacity limit** of 1 cup
|
||||
- Once full, a glass stops "receiving" more — it just passes overflow down
|
||||
- The overflow depends on glasses above being full, which creates complex dependencies
|
||||
|
||||
The simulation approach elegantly handles all these cases without complex math.
|
||||
wrong_approach: "Combinatorial formula based on paths"
|
||||
correct_approach: "Simulate the overflow process row by row"
|
||||
|
||||
- title: Forgetting the Capacity Limit
|
||||
description: |
|
||||
When tracking liquid in each glass, it's easy to forget that a glass can never hold more than 1 cup in the final answer. If you track overflow separately, make sure to cap the result.
|
||||
|
||||
For example, if `dp[query_row][query_glass] = 2.5`, the answer is still `1.0` — the glass is full, and the excess has already been distributed downward.
|
||||
wrong_approach: "Return dp[query_row][query_glass] directly"
|
||||
correct_approach: "Return min(1.0, dp[query_row][query_glass])"
|
||||
|
||||
- title: Off-by-One Errors in Row Processing
|
||||
description: |
|
||||
Row `i` has `i+1` glasses (0-indexed). When distributing overflow from glass `(i, j)`, the children are at positions `(i+1, j)` and `(i+1, j+1)`. Make sure you:
|
||||
|
||||
- Process exactly `i+1` glasses in row `i`
|
||||
- Don't go beyond `query_row` (unnecessary computation)
|
||||
- Handle the boundary glasses correctly (they still have two children)
|
||||
|
||||
key_takeaways:
|
||||
- "**Simulation over calculation**: When physical processes have complex rules (capacity limits, cascading effects), simulation is often cleaner than closed-form solutions"
|
||||
- "**Row-by-row DP**: When data flows in one direction (like gravity), process in that direction and each step only depends on the previous"
|
||||
- "**Track total flow, not final state**: By tracking how much liquid *enters* each glass (not just how much it holds), we naturally handle overflow distribution"
|
||||
- "**This pattern appears in**: water flow problems, Pascal's Triangle variants, and any cascading/spreading simulation"
|
||||
|
||||
time_complexity: "O(query_row²). We process at most `query_row + 1` rows, and row `i` has `i + 1` glasses, giving us 1 + 2 + ... + (query_row + 1) = O(query_row²) operations."
|
||||
space_complexity: "O(query_row²). We store the champagne amount for each glass up to `query_row`. This can be optimised to O(query_row) using two-row DP, but O(query_row²) is acceptable given the constraint `query_row < 100`."
|
||||
|
||||
solutions:
|
||||
- approach_name: Row-by-Row Simulation
|
||||
is_optimal: true
|
||||
code: |
|
||||
def champagne_tower(poured: int, query_row: int, query_glass: int) -> float:
|
||||
# dp[i][j] = total champagne that has flowed into glass (i, j)
|
||||
dp = [[0.0] * (query_row + 2) for _ in range(query_row + 2)]
|
||||
|
||||
# All champagne starts at the top glass
|
||||
dp[0][0] = float(poured)
|
||||
|
||||
# Process each row, distributing overflow to the row below
|
||||
for row in range(query_row + 1):
|
||||
for col in range(row + 1):
|
||||
# Calculate overflow (amount exceeding capacity of 1)
|
||||
overflow = dp[row][col] - 1.0
|
||||
|
||||
if overflow > 0:
|
||||
# Half goes to left child, half to right child
|
||||
dp[row + 1][col] += overflow / 2.0
|
||||
dp[row + 1][col + 1] += overflow / 2.0
|
||||
|
||||
# Return how full the queried glass is (capped at 1.0)
|
||||
return min(1.0, dp[query_row][query_glass])
|
||||
explanation: |
|
||||
**Time Complexity:** O(query_row²) — We iterate through a triangular portion of the pyramid.
|
||||
|
||||
**Space Complexity:** O(query_row²) — We store the state for all glasses up to the query row.
|
||||
|
||||
We simulate the champagne flow by tracking how much liquid enters each glass. When a glass overflows, we distribute the excess equally to its two children below. The final answer is capped at 1.0 since that's the maximum a glass can hold.
|
||||
|
||||
- approach_name: Space-Optimised (Two Rows)
|
||||
is_optimal: false
|
||||
code: |
|
||||
def champagne_tower(poured: int, query_row: int, query_glass: int) -> float:
|
||||
# Only keep track of the current row
|
||||
current_row = [float(poured)]
|
||||
|
||||
for row in range(query_row):
|
||||
# Next row has one more glass
|
||||
next_row = [0.0] * (row + 2)
|
||||
|
||||
for col in range(len(current_row)):
|
||||
# Calculate overflow from current glass
|
||||
overflow = current_row[col] - 1.0
|
||||
|
||||
if overflow > 0:
|
||||
# Distribute to children in next row
|
||||
next_row[col] += overflow / 2.0
|
||||
next_row[col + 1] += overflow / 2.0
|
||||
|
||||
current_row = next_row
|
||||
|
||||
# Handle edge case: if query_row is 0, current_row is still [poured]
|
||||
if query_glass < len(current_row):
|
||||
return min(1.0, current_row[query_glass])
|
||||
return 0.0
|
||||
explanation: |
|
||||
**Time Complexity:** O(query_row²) — Same as the standard approach.
|
||||
|
||||
**Space Complexity:** O(query_row) — We only store two rows at a time instead of the entire pyramid.
|
||||
|
||||
This optimisation is useful when memory is constrained, but given the problem's constraint of `query_row < 100`, the standard O(query_row²) space solution is perfectly acceptable and easier to understand.
|
||||
@@ -0,0 +1,230 @@
|
||||
title: Change Minimum Characters to Satisfy One of Three Conditions
|
||||
slug: change-minimum-characters-to-satisfy-one-of-three-conditions
|
||||
difficulty: medium
|
||||
leetcode_id: 1737
|
||||
leetcode_url: https://leetcode.com/problems/change-minimum-characters-to-satisfy-one-of-three-conditions/
|
||||
categories:
|
||||
- strings
|
||||
- hash-tables
|
||||
patterns:
|
||||
- prefix-sum
|
||||
|
||||
description: |
|
||||
You are given two strings `a` and `b` that consist of lowercase letters. In one operation, you can change any character in `a` or `b` to **any lowercase letter**.
|
||||
|
||||
Your goal is to satisfy **one** of the following three conditions:
|
||||
|
||||
- **Every** letter in `a` is **strictly less** than **every** letter in `b` in the alphabet.
|
||||
- **Every** letter in `b` is **strictly less** than **every** letter in `a` in the alphabet.
|
||||
- **Both** `a` and `b` consist of **only one** distinct letter.
|
||||
|
||||
Return *the **minimum** number of operations needed to achieve your goal*.
|
||||
|
||||
constraints: |
|
||||
- `1 <= a.length, b.length <= 10^5`
|
||||
- `a` and `b` consist only of lowercase letters.
|
||||
|
||||
examples:
|
||||
- input: 'a = "aba", b = "caa"'
|
||||
output: "2"
|
||||
explanation: "Consider the best way to make each condition true: 1) Change b to \"ccc\" in 2 operations, then every letter in a is less than every letter in b. 2) Change a to \"bbb\" and b to \"aaa\" in 3 operations, then every letter in b is less than every letter in a. 3) Change a to \"aaa\" and b to \"aaa\" in 2 operations, then a and b consist of one distinct letter. The best way was done in 2 operations (either condition 1 or condition 3)."
|
||||
- input: 'a = "dabadd", b = "cda"'
|
||||
output: "3"
|
||||
explanation: 'The best way is to make condition 1 true by changing b to "eee".'
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Imagine the 26 lowercase letters arranged on a number line from `a` (0) to `z` (25). Each condition asks us to rearrange the characters so that certain constraints hold.
|
||||
|
||||
For **conditions 1 and 2**, we need to find a "dividing line" between two letters where all characters in one string fall below the line and all characters in the other string fall above it. Think of it like separating two groups of students by height — we pick a threshold, and everyone shorter goes left, everyone taller goes right.
|
||||
|
||||
For **condition 3**, we're making both strings consist of a single letter — like painting everything the same colour. The cost is the total number of characters that aren't already that colour.
|
||||
|
||||
The key insight is that we don't need to try every possible way to transform the strings. Instead, we can:
|
||||
- Count character frequencies in both strings
|
||||
- For conditions 1 and 2, try each possible "dividing point" between adjacent letters (25 choices: between 'a' and 'b', between 'b' and 'c', etc.)
|
||||
- For condition 3, try each letter as the target (26 choices)
|
||||
|
||||
Using prefix sums lets us efficiently calculate how many characters would need to change for each dividing point.
|
||||
|
||||
approach: |
|
||||
We solve this by evaluating all three conditions and taking the minimum cost.
|
||||
|
||||
**Step 1: Count character frequencies**
|
||||
|
||||
- `count_a[i]`: Number of occurrences of the i<sup>th</sup> letter (0-indexed, where 0 = 'a') in string `a`
|
||||
- `count_b[i]`: Number of occurrences of the i<sup>th</sup> letter in string `b`
|
||||
|
||||
|
||||
|
||||
**Step 2: Compute prefix sums**
|
||||
|
||||
- `prefix_a[i]`: Total characters in `a` that are less than the i<sup>th</sup> letter
|
||||
- `prefix_b[i]`: Total characters in `b` that are less than the i<sup>th</sup> letter
|
||||
|
||||
These let us quickly answer: "How many characters in string X are below letter Y?"
|
||||
|
||||
|
||||
|
||||
**Step 3: Evaluate condition 1 (all of `a` < all of `b`)**
|
||||
|
||||
- For each possible dividing point `i` from 1 to 25 (between letters):
|
||||
- Characters in `a` that need to change: those >= letter `i` → `len(a) - prefix_a[i]`
|
||||
- Characters in `b` that need to change: those < letter `i` → `prefix_b[i]`
|
||||
- Total cost: `len(a) - prefix_a[i] + prefix_b[i]`
|
||||
|
||||
|
||||
|
||||
**Step 4: Evaluate condition 2 (all of `b` < all of `a`)**
|
||||
|
||||
- Same logic but swap `a` and `b`
|
||||
- Cost for dividing point `i`: `len(b) - prefix_b[i] + prefix_a[i]`
|
||||
|
||||
|
||||
|
||||
**Step 5: Evaluate condition 3 (both strings same letter)**
|
||||
|
||||
- For each letter `i` from 0 to 25:
|
||||
- Cost = total characters minus those already equal to letter `i`
|
||||
- Cost = `len(a) + len(b) - count_a[i] - count_b[i]`
|
||||
|
||||
|
||||
|
||||
**Step 6: Return the minimum**
|
||||
|
||||
- Return the minimum cost across all evaluated options
|
||||
|
||||
common_pitfalls:
|
||||
- title: Off-by-One Errors in Dividing Points
|
||||
description: |
|
||||
When checking conditions 1 and 2, the dividing point must be *between* letters, not *at* a letter. We iterate `i` from 1 to 25 (not 0 to 25) because:
|
||||
- At `i = 1`, all of `a` must be 'a' (letter 0), and all of `b` must be >= 'b' (letter 1)
|
||||
- At `i = 25`, all of `a` must be < 'z', and all of `b` must be 'z'
|
||||
|
||||
There's no valid dividing point at `i = 0` (nothing is less than 'a') or beyond 25.
|
||||
wrong_approach: "Iterate from 0 to 26"
|
||||
correct_approach: "Iterate dividing points from 1 to 25"
|
||||
|
||||
- title: Confusing Strict Less-Than with Less-Than-or-Equal
|
||||
description: |
|
||||
The condition says "strictly less than", meaning if we pick dividing point `i`, all characters in `a` must have values `< i`, and all in `b` must have values `>= i`. Getting this wrong by including equality will give incorrect results.
|
||||
|
||||
For example, if dividing at 'c' (index 2), string `a` can only have 'a' or 'b', not 'c'.
|
||||
wrong_approach: "Allow a to have characters <= dividing point"
|
||||
correct_approach: "String a must have all characters < dividing point"
|
||||
|
||||
- title: Forgetting Condition 3
|
||||
description: |
|
||||
Some solutions focus only on the "separation" conditions and forget that making both strings the same letter might be cheaper. For example, if both strings are already `"aaa"` and `"aaa"`, condition 3 costs 0, while conditions 1 and 2 would require changes.
|
||||
wrong_approach: "Only check conditions 1 and 2"
|
||||
correct_approach: "Check all three conditions and take the minimum"
|
||||
|
||||
key_takeaways:
|
||||
- "**Prefix sums for range queries**: Pre-computing cumulative sums allows O(1) lookups for 'how many elements are below threshold X'"
|
||||
- "**Enumerate the boundary**: When splitting data into two groups by a threshold, try all possible thresholds (26 letters means 25 dividing points)"
|
||||
- "**Evaluate all options**: When a problem has multiple valid end states (three conditions here), compute the cost for each and take the minimum"
|
||||
- "**Character frequency counting**: A common pattern for string problems — count frequencies first, then process the counts"
|
||||
|
||||
time_complexity: "O(n + m). We count frequencies in O(n + m) where n and m are the string lengths, then iterate through 26 letters a constant number of times."
|
||||
space_complexity: "O(1). We use fixed-size arrays of length 26 for frequency counts and prefix sums, independent of input size."
|
||||
|
||||
solutions:
|
||||
- approach_name: Prefix Sum with Frequency Counting
|
||||
is_optimal: true
|
||||
code: |
|
||||
def min_characters(a: str, b: str) -> int:
|
||||
# Count frequency of each letter in both strings
|
||||
count_a = [0] * 26
|
||||
count_b = [0] * 26
|
||||
|
||||
for c in a:
|
||||
count_a[ord(c) - ord('a')] += 1
|
||||
for c in b:
|
||||
count_b[ord(c) - ord('a')] += 1
|
||||
|
||||
# Build prefix sums: prefix[i] = count of chars < letter i
|
||||
prefix_a = [0] * 27
|
||||
prefix_b = [0] * 27
|
||||
for i in range(26):
|
||||
prefix_a[i + 1] = prefix_a[i] + count_a[i]
|
||||
prefix_b[i + 1] = prefix_b[i] + count_b[i]
|
||||
|
||||
len_a, len_b = len(a), len(b)
|
||||
result = len_a + len_b # Worst case: change everything
|
||||
|
||||
# Condition 1: all of a < all of b
|
||||
# Condition 2: all of b < all of a
|
||||
# Try each dividing point from 1 to 25
|
||||
for i in range(1, 26):
|
||||
# Condition 1: a's chars must be < i, b's chars must be >= i
|
||||
cost1 = (len_a - prefix_a[i]) + prefix_b[i]
|
||||
# Condition 2: b's chars must be < i, a's chars must be >= i
|
||||
cost2 = (len_b - prefix_b[i]) + prefix_a[i]
|
||||
result = min(result, cost1, cost2)
|
||||
|
||||
# Condition 3: both strings become same letter
|
||||
for i in range(26):
|
||||
# Cost = chars not already equal to letter i
|
||||
cost3 = (len_a - count_a[i]) + (len_b - count_b[i])
|
||||
result = min(result, cost3)
|
||||
|
||||
return result
|
||||
explanation: |
|
||||
**Time Complexity:** O(n + m) — Linear pass to count frequencies, then constant-time operations over 26 letters.
|
||||
|
||||
**Space Complexity:** O(1) — Fixed arrays of size 26/27 regardless of input size.
|
||||
|
||||
We count character frequencies, build prefix sums, then evaluate all possible ways to satisfy each condition. The prefix sums let us efficiently compute how many characters fall below any threshold.
|
||||
|
||||
- approach_name: Brute Force (Conceptual)
|
||||
is_optimal: false
|
||||
code: |
|
||||
def min_characters_brute(a: str, b: str) -> int:
|
||||
# This is a conceptual illustration - actual brute force
|
||||
# would enumerate all possible transformations
|
||||
|
||||
# For each condition, try all valid target configurations
|
||||
result = len(a) + len(b)
|
||||
|
||||
# Condition 3: try each letter as the target
|
||||
for target in range(26):
|
||||
cost = 0
|
||||
for c in a:
|
||||
if ord(c) - ord('a') != target:
|
||||
cost += 1
|
||||
for c in b:
|
||||
if ord(c) - ord('a') != target:
|
||||
cost += 1
|
||||
result = min(result, cost)
|
||||
|
||||
# Condition 1: try each dividing point
|
||||
for div in range(1, 26):
|
||||
cost = 0
|
||||
# Count chars in a that are >= div (need to change)
|
||||
for c in a:
|
||||
if ord(c) - ord('a') >= div:
|
||||
cost += 1
|
||||
# Count chars in b that are < div (need to change)
|
||||
for c in b:
|
||||
if ord(c) - ord('a') < div:
|
||||
cost += 1
|
||||
result = min(result, cost)
|
||||
|
||||
# Condition 2: similar but swap roles
|
||||
for div in range(1, 26):
|
||||
cost = 0
|
||||
for c in b:
|
||||
if ord(c) - ord('a') >= div:
|
||||
cost += 1
|
||||
for c in a:
|
||||
if ord(c) - ord('a') < div:
|
||||
cost += 1
|
||||
result = min(result, cost)
|
||||
|
||||
return result
|
||||
explanation: |
|
||||
**Time Complexity:** O(26 × (n + m)) — For each of 26 letters and 25 dividing points, we scan both strings.
|
||||
|
||||
**Space Complexity:** O(1) — No additional data structures.
|
||||
|
||||
This approach recounts characters for each target/dividing point instead of pre-computing frequencies. While still linear in terms of big-O (since 26 is constant), it's less efficient than the prefix sum approach because it repeatedly scans the strings.
|
||||
219
backend/data/questions/cheapest-flights-within-k-stops.yaml
Normal file
219
backend/data/questions/cheapest-flights-within-k-stops.yaml
Normal file
@@ -0,0 +1,219 @@
|
||||
title: Cheapest Flights Within K Stops
|
||||
slug: cheapest-flights-within-k-stops
|
||||
difficulty: medium
|
||||
leetcode_id: 787
|
||||
leetcode_url: https://leetcode.com/problems/cheapest-flights-within-k-stops/
|
||||
categories:
|
||||
- graphs
|
||||
- dynamic-programming
|
||||
patterns:
|
||||
- bfs
|
||||
- dynamic-programming
|
||||
|
||||
description: |
|
||||
There are `n` cities connected by some number of flights. You are given an array `flights` where `flights[i] = [from_i, to_i, price_i]` indicates that there is a flight from city `from_i` to city `to_i` with cost `price_i`.
|
||||
|
||||
You are also given three integers `src`, `dst`, and `k`, return *the cheapest price* from `src` to `dst` with at most `k` stops. If there is no such route, return `-1`.
|
||||
|
||||
constraints: |
|
||||
- `2 <= n <= 100`
|
||||
- `0 <= flights.length <= (n * (n - 1) / 2)`
|
||||
- `flights[i].length == 3`
|
||||
- `0 <= from_i, to_i < n`
|
||||
- `from_i != to_i`
|
||||
- `1 <= price_i <= 10^4`
|
||||
- There will not be any multiple flights between two cities.
|
||||
- `0 <= src, dst, k < n`
|
||||
- `src != dst`
|
||||
|
||||
examples:
|
||||
- input: "n = 4, flights = [[0,1,100],[1,2,100],[2,0,100],[1,3,600],[2,3,200]], src = 0, dst = 3, k = 1"
|
||||
output: "700"
|
||||
explanation: "The optimal path with at most 1 stop from city 0 to 3 is 0 → 1 → 3 with cost 100 + 600 = 700. Note that the path 0 → 1 → 2 → 3 is cheaper (400) but is invalid because it uses 2 stops."
|
||||
- input: "n = 3, flights = [[0,1,100],[1,2,100],[0,2,500]], src = 0, dst = 2, k = 1"
|
||||
output: "200"
|
||||
explanation: "The optimal path with at most 1 stop from city 0 to 2 is 0 → 1 → 2 with cost 100 + 100 = 200."
|
||||
- input: "n = 3, flights = [[0,1,100],[1,2,100],[0,2,500]], src = 0, dst = 2, k = 0"
|
||||
output: "500"
|
||||
explanation: "The optimal path with no stops from city 0 to 2 is the direct flight 0 → 2 with cost 500."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Imagine you're planning a trip with a strict layover limit. You can take connecting flights, but you can only stop at most `k` intermediate cities. The goal is to find the cheapest way to get from your origin to your destination within this constraint.
|
||||
|
||||
This is a **shortest path problem with a twist**: traditional algorithms like Dijkstra's find the absolute shortest path, but here we need the shortest path *with a constraint on the number of edges (stops)*. A cheaper path that uses too many stops is invalid.
|
||||
|
||||
Think of it like this: at each "level" of stops, we want to know the cheapest way to reach each city. Starting from `src` with 0 stops used, we can relax the costs to neighboring cities. Then with 1 stop used, we can reach cities two hops away, and so on. After `k+1` iterations (since `k` stops means `k+1` edges), we check the cost to reach `dst`.
|
||||
|
||||
The **Bellman-Ford algorithm** is perfectly suited for this because it relaxes edges level by level, naturally incorporating the stop constraint. We simply limit the number of relaxation rounds to `k+1`.
|
||||
|
||||
approach: |
|
||||
We solve this using the **Bellman-Ford Algorithm** modified for at most `k` stops:
|
||||
|
||||
**Step 1: Initialise the distance array**
|
||||
|
||||
- Create a `prices` array of size `n`, initialised to infinity for all cities
|
||||
- Set `prices[src] = 0` since we start at the source with zero cost
|
||||
|
||||
|
||||
|
||||
**Step 2: Relax edges for k+1 iterations**
|
||||
|
||||
- We need at most `k+1` edges to make `k` stops (source → stop1 → stop2 → ... → destination)
|
||||
- For each iteration, create a **copy of the current prices** (critical for correctness)
|
||||
- For each flight `[from, to, price]`, check if we can improve the cost to `to`:
|
||||
- If `prices[from] + price < temp[to]`, update `temp[to]`
|
||||
- After processing all edges, update `prices = temp`
|
||||
|
||||
|
||||
|
||||
**Step 3: Return the result**
|
||||
|
||||
- If `prices[dst]` is still infinity, return `-1` (no valid path within `k` stops)
|
||||
- Otherwise, return `prices[dst]`
|
||||
|
||||
|
||||
|
||||
The key insight is using a temporary copy each round. This ensures we only use paths from the *previous* iteration, preventing us from using more edges than allowed in a single round.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Using Standard Dijkstra's Algorithm
|
||||
description: |
|
||||
Standard Dijkstra's always finds the shortest path regardless of the number of edges. It might return a path with more than `k` stops if that path is cheaper.
|
||||
|
||||
For example, with `k = 1`, Dijkstra's might find a path with 5 stops if it's the cheapest overall. We need an algorithm that respects the stop limit.
|
||||
wrong_approach: "Standard Dijkstra's without stop tracking"
|
||||
correct_approach: "Bellman-Ford with k+1 iterations or BFS with stop counting"
|
||||
|
||||
- title: Not Using a Temporary Copy
|
||||
description: |
|
||||
When relaxing edges, if you update `prices` directly without a copy, you might use an edge that was just updated in the same iteration. This means you could take multiple edges in what should be a single "round".
|
||||
|
||||
For example, if `prices[A]` is updated and then immediately used to update `prices[B]`, you've effectively used two edges in one iteration.
|
||||
wrong_approach: "Update prices array directly during relaxation"
|
||||
correct_approach: "Copy prices to temp before each round, update temp, then assign back"
|
||||
|
||||
- title: Confusion About k Stops vs k+1 Edges
|
||||
description: |
|
||||
With `k` stops, you can traverse `k+1` edges (flights). If you only iterate `k` times instead of `k+1`, you'll miss valid paths.
|
||||
|
||||
Example: `k = 1` means one intermediate stop, so paths like `src → city → dst` are valid (2 edges). You need 2 iterations of Bellman-Ford.
|
||||
wrong_approach: "Iterate exactly k times"
|
||||
correct_approach: "Iterate k+1 times"
|
||||
|
||||
- title: Forgetting to Handle No Valid Path
|
||||
description: |
|
||||
If there's no path from `src` to `dst` within `k` stops, `prices[dst]` remains infinity. You must check for this and return `-1`.
|
||||
|
||||
key_takeaways:
|
||||
- "**Bellman-Ford for constrained shortest paths**: When you need to limit the number of edges, Bellman-Ford's level-by-level relaxation is ideal"
|
||||
- "**Temporary copy prevents over-relaxation**: Using a copy each round ensures we don't use more edges than allowed"
|
||||
- "**BFS is an alternative**: BFS level-by-level also works, treating each level as one more stop"
|
||||
- "**Foundation for flight booking problems**: This pattern appears in real-world scenarios like finding cheapest flights with layover limits"
|
||||
|
||||
time_complexity: "O(k * E) where E is the number of flights. We iterate k+1 times, and each iteration processes all E edges."
|
||||
space_complexity: "O(n). We store the prices array of size n, plus a temporary copy of size n."
|
||||
|
||||
solutions:
|
||||
- approach_name: Bellman-Ford
|
||||
is_optimal: true
|
||||
code: |
|
||||
def findCheapestPrice(n: int, flights: list[list[int]], src: int, dst: int, k: int) -> int:
|
||||
# Initialise prices to infinity, source is 0
|
||||
prices = [float('inf')] * n
|
||||
prices[src] = 0
|
||||
|
||||
# Relax edges k+1 times (k stops = k+1 edges)
|
||||
for _ in range(k + 1):
|
||||
# Use a copy to prevent using multiple edges in one round
|
||||
temp = prices.copy()
|
||||
|
||||
# Try to relax each edge
|
||||
for from_city, to_city, price in flights:
|
||||
# Can we reach to_city cheaper via from_city?
|
||||
if prices[from_city] != float('inf'):
|
||||
temp[to_city] = min(temp[to_city], prices[from_city] + price)
|
||||
|
||||
prices = temp
|
||||
|
||||
# Return -1 if destination unreachable within k stops
|
||||
return prices[dst] if prices[dst] != float('inf') else -1
|
||||
explanation: |
|
||||
**Time Complexity:** O(k * E) — We perform k+1 iterations, each processing all E edges.
|
||||
|
||||
**Space Complexity:** O(n) — Two arrays of size n (prices and temp).
|
||||
|
||||
This is the classic Bellman-Ford approach adapted for the stop constraint. By limiting iterations to k+1, we naturally enforce the maximum number of edges allowed.
|
||||
|
||||
- approach_name: BFS with Level-by-Level Relaxation
|
||||
is_optimal: false
|
||||
code: |
|
||||
from collections import defaultdict, deque
|
||||
|
||||
def findCheapestPrice(n: int, flights: list[list[int]], src: int, dst: int, k: int) -> int:
|
||||
# Build adjacency list
|
||||
graph = defaultdict(list)
|
||||
for from_city, to_city, price in flights:
|
||||
graph[from_city].append((to_city, price))
|
||||
|
||||
# prices[city] = minimum cost to reach city
|
||||
prices = [float('inf')] * n
|
||||
prices[src] = 0
|
||||
|
||||
# BFS: (current_city, current_cost)
|
||||
queue = deque([(src, 0)])
|
||||
stops = 0
|
||||
|
||||
while queue and stops <= k:
|
||||
# Process all nodes at current level
|
||||
for _ in range(len(queue)):
|
||||
city, cost = queue.popleft()
|
||||
|
||||
# Explore all neighbors
|
||||
for next_city, price in graph[city]:
|
||||
new_cost = cost + price
|
||||
|
||||
# Only add to queue if this is a better path
|
||||
if new_cost < prices[next_city]:
|
||||
prices[next_city] = new_cost
|
||||
queue.append((next_city, new_cost))
|
||||
|
||||
stops += 1
|
||||
|
||||
return prices[dst] if prices[dst] != float('inf') else -1
|
||||
explanation: |
|
||||
**Time Complexity:** O(k * E) — Similar to Bellman-Ford, we process edges level by level.
|
||||
|
||||
**Space Complexity:** O(n + E) — Adjacency list takes O(E), queue can hold O(n) nodes per level.
|
||||
|
||||
This BFS approach processes cities level by level, where each level represents one additional stop. It's conceptually similar to Bellman-Ford but uses a queue structure.
|
||||
|
||||
- approach_name: Dynamic Programming (2D)
|
||||
is_optimal: false
|
||||
code: |
|
||||
def findCheapestPrice(n: int, flights: list[list[int]], src: int, dst: int, k: int) -> int:
|
||||
# dp[stops][city] = minimum cost to reach city using exactly 'stops' stops
|
||||
INF = float('inf')
|
||||
dp = [[INF] * n for _ in range(k + 2)]
|
||||
dp[0][src] = 0
|
||||
|
||||
# Fill DP table
|
||||
for stops in range(1, k + 2):
|
||||
# Carry forward: can reach city with fewer stops
|
||||
dp[stops] = dp[stops - 1].copy()
|
||||
|
||||
# Try each flight
|
||||
for from_city, to_city, price in flights:
|
||||
if dp[stops - 1][from_city] != INF:
|
||||
dp[stops][to_city] = min(
|
||||
dp[stops][to_city],
|
||||
dp[stops - 1][from_city] + price
|
||||
)
|
||||
|
||||
return dp[k + 1][dst] if dp[k + 1][dst] != INF else -1
|
||||
explanation: |
|
||||
**Time Complexity:** O(k * E) — We iterate through k+2 rows and process all E edges per row.
|
||||
|
||||
**Space Complexity:** O(k * n) — 2D DP table with k+2 rows and n columns.
|
||||
|
||||
This explicit DP formulation makes the state clear: `dp[i][j]` represents the minimum cost to reach city `j` using at most `i` edges. The Bellman-Ford solution is essentially this DP with space optimisation.
|
||||
@@ -0,0 +1,165 @@
|
||||
title: Check Array Formation Through Concatenation
|
||||
slug: check-array-formation-through-concatenation
|
||||
difficulty: easy
|
||||
leetcode_id: 1640
|
||||
leetcode_url: https://leetcode.com/problems/check-array-formation-through-concatenation/
|
||||
categories:
|
||||
- arrays
|
||||
- hash-tables
|
||||
patterns:
|
||||
- greedy
|
||||
|
||||
description: |
|
||||
You are given an array of **distinct** integers `arr` and an array of integer arrays `pieces`, where the integers in `pieces` are **distinct**. Your goal is to form `arr` by concatenating the arrays in `pieces` **in any order**. However, you are **not** allowed to reorder the integers in each array `pieces[i]`.
|
||||
|
||||
Return `true` *if it is possible to form the array* `arr` *from* `pieces`. Otherwise, return `false`.
|
||||
|
||||
constraints: |
|
||||
- `1 <= pieces.length <= arr.length <= 100`
|
||||
- `sum(pieces[i].length) == arr.length`
|
||||
- `1 <= pieces[i].length <= arr.length`
|
||||
- `1 <= arr[i], pieces[i][j] <= 100`
|
||||
- The integers in `arr` are **distinct**
|
||||
- The integers in `pieces` are **distinct** (if we flatten `pieces` into a 1D array, all integers are distinct)
|
||||
|
||||
examples:
|
||||
- input: "arr = [15,88], pieces = [[88],[15]]"
|
||||
output: "true"
|
||||
explanation: "Concatenate [15] then [88] to form [15, 88]."
|
||||
- input: "arr = [49,18,16], pieces = [[16,18,49]]"
|
||||
output: "false"
|
||||
explanation: "Even though the numbers match, we cannot reorder pieces[0]. The piece [16,18,49] would place 16 first, but arr requires 49 first."
|
||||
- input: "arr = [91,4,64,78], pieces = [[78],[4,64],[91]]"
|
||||
output: "true"
|
||||
explanation: "Concatenate [91] then [4,64] then [78] to form [91, 4, 64, 78]."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Think of this problem like assembling a jigsaw puzzle where each piece has a fixed internal structure. You have pre-formed "chunks" of numbers that cannot be rearranged internally, but you can choose which chunk to place next.
|
||||
|
||||
The key insight is that **each number is distinct**, which means every number in `arr` appears in exactly one piece. This is powerful because it means we can uniquely identify which piece should come next by looking at the **first element** of each piece.
|
||||
|
||||
Imagine walking through `arr` from left to right. At each position, we ask: "Which piece starts with this number?" If we find such a piece, we verify that its subsequent elements match the next positions in `arr`. If they do, we've successfully placed that piece and can move forward. If no piece starts with the current number, or if the piece's elements don't match, it's impossible to form `arr`.
|
||||
|
||||
The distinctness constraint transforms this from a complex permutation problem into a simple greedy matching problem.
|
||||
|
||||
approach: |
|
||||
We solve this using a **Hash Map + Greedy Matching** approach:
|
||||
|
||||
**Step 1: Build a lookup map**
|
||||
|
||||
- Create a hash map that maps the **first element** of each piece to the entire piece
|
||||
- Since all integers are distinct, each first element uniquely identifies its piece
|
||||
- This allows O(1) lookup to find which piece should be placed next
|
||||
|
||||
|
||||
|
||||
**Step 2: Iterate through arr greedily**
|
||||
|
||||
- Start at index `i = 0` in `arr`
|
||||
- Look up `arr[i]` in our hash map to find the piece that should start here
|
||||
- If no piece starts with `arr[i]`, return `false` — we can't form `arr`
|
||||
- If we find a piece, verify that **all elements** in that piece match the corresponding positions in `arr`
|
||||
- If any element doesn't match, return `false`
|
||||
- If all elements match, advance `i` by the length of the piece we just placed
|
||||
|
||||
|
||||
|
||||
**Step 3: Return the result**
|
||||
|
||||
- If we successfully process all of `arr` (i.e., `i` reaches `len(arr)`), return `true`
|
||||
- The constraint `sum(pieces[i].length) == arr.length` ensures we'll use all pieces exactly once if successful
|
||||
|
||||
common_pitfalls:
|
||||
- title: Trying to Reorder Pieces Internally
|
||||
description: |
|
||||
A common misunderstanding is thinking you can rearrange elements within a piece. The problem explicitly states that you **cannot** reorder integers within each `pieces[i]`.
|
||||
|
||||
For example, with `arr = [49, 18, 16]` and `pieces = [[16, 18, 49]]`, you cannot rearrange the piece to `[49, 18, 16]`. The piece must be used as-is.
|
||||
wrong_approach: "Treating pieces as unordered sets"
|
||||
correct_approach: "Use pieces as fixed-order arrays that must match contiguously"
|
||||
|
||||
- title: Linear Search for Each Element
|
||||
description: |
|
||||
Without a hash map, you might search through all pieces for each position in `arr`, resulting in O(n × m) complexity where m is the number of pieces.
|
||||
|
||||
While this would still pass given the small constraints (`n <= 100`), using a hash map reduces lookups to O(1) and makes the solution cleaner and more scalable.
|
||||
wrong_approach: "Nested loops searching all pieces"
|
||||
correct_approach: "Hash map keyed by first element for O(1) lookup"
|
||||
|
||||
- title: Not Validating the Entire Piece
|
||||
description: |
|
||||
It's tempting to only check if the first element matches, but you must verify that **all subsequent elements** in the piece match the corresponding positions in `arr`.
|
||||
|
||||
For example, if `arr = [1, 3, 2]` and a piece is `[1, 2, 3]`, the first element `1` matches, but the rest doesn't. You must check the entire piece.
|
||||
wrong_approach: "Only checking the first element of each piece"
|
||||
correct_approach: "Verify all elements in the piece match contiguously in arr"
|
||||
|
||||
key_takeaways:
|
||||
- "**Distinctness enables unique identification**: When elements are distinct, the first element of each piece uniquely identifies that piece"
|
||||
- "**Hash maps for fast lookup**: Mapping first elements to pieces allows O(1) identification of which piece belongs at each position"
|
||||
- "**Greedy works when there's no choice**: Since each position in `arr` can only match one piece, greedy matching finds the solution if one exists"
|
||||
- "**Constraint exploitation**: The `sum(pieces[i].length) == arr.length` constraint guarantees we'll use all pieces if successful"
|
||||
|
||||
time_complexity: "O(n). We iterate through `arr` once and perform O(1) lookups. Each element in `pieces` is also checked exactly once during verification."
|
||||
space_complexity: "O(n). We store all pieces in a hash map, where n is the total number of elements across all pieces (which equals `arr.length`)."
|
||||
|
||||
solutions:
|
||||
- approach_name: Hash Map Lookup
|
||||
is_optimal: true
|
||||
code: |
|
||||
def can_form_array(arr: list[int], pieces: list[list[int]]) -> bool:
|
||||
# Map first element of each piece to the entire piece
|
||||
piece_map = {piece[0]: piece for piece in pieces}
|
||||
|
||||
i = 0
|
||||
while i < len(arr):
|
||||
# Find the piece that should start at position i
|
||||
if arr[i] not in piece_map:
|
||||
# No piece starts with this element
|
||||
return False
|
||||
|
||||
piece = piece_map[arr[i]]
|
||||
|
||||
# Verify all elements in this piece match arr
|
||||
for val in piece:
|
||||
if i >= len(arr) or arr[i] != val:
|
||||
return False
|
||||
i += 1
|
||||
|
||||
return True
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Each element in `arr` is visited exactly once.
|
||||
|
||||
**Space Complexity:** O(n) — The hash map stores all pieces.
|
||||
|
||||
We build a hash map keyed by the first element of each piece, then greedily match pieces to positions in `arr`. The distinctness constraint ensures each lookup is unambiguous.
|
||||
|
||||
- approach_name: Brute Force with Linear Search
|
||||
is_optimal: false
|
||||
code: |
|
||||
def can_form_array(arr: list[int], pieces: list[list[int]]) -> bool:
|
||||
i = 0
|
||||
while i < len(arr):
|
||||
# Find a piece that starts with arr[i]
|
||||
found = False
|
||||
for piece in pieces:
|
||||
if piece[0] == arr[i]:
|
||||
# Check if this piece matches
|
||||
for val in piece:
|
||||
if i >= len(arr) or arr[i] != val:
|
||||
return False
|
||||
i += 1
|
||||
found = True
|
||||
break
|
||||
|
||||
if not found:
|
||||
return False
|
||||
|
||||
return True
|
||||
explanation: |
|
||||
**Time Complexity:** O(n × m) — For each position in `arr`, we potentially search all `m` pieces.
|
||||
|
||||
**Space Complexity:** O(1) — No additional data structures used.
|
||||
|
||||
This approach searches through all pieces to find one that starts with the current element. While correct, it's less efficient than the hash map approach. Given the small constraints (`n, m <= 100`), both solutions pass.
|
||||
181
backend/data/questions/check-completeness-of-a-binary-tree.yaml
Normal file
181
backend/data/questions/check-completeness-of-a-binary-tree.yaml
Normal file
@@ -0,0 +1,181 @@
|
||||
title: Check Completeness of a Binary Tree
|
||||
slug: check-completeness-of-a-binary-tree
|
||||
difficulty: medium
|
||||
leetcode_id: 958
|
||||
leetcode_url: https://leetcode.com/problems/check-completeness-of-a-binary-tree/
|
||||
categories:
|
||||
- trees
|
||||
patterns:
|
||||
- bfs
|
||||
- tree-traversal
|
||||
|
||||
description: |
|
||||
Given the `root` of a binary tree, determine if it is a *complete binary tree*.
|
||||
|
||||
In a **complete binary tree**, every level, except possibly the last, is completely filled, and all nodes in the last level are as far left as possible. It can have between `1` and `2^h` nodes inclusive at the last level `h`.
|
||||
|
||||
constraints: |
|
||||
- `1 <= number of nodes <= 100`
|
||||
- `1 <= Node.val <= 1000`
|
||||
|
||||
examples:
|
||||
- input: "root = [1,2,3,4,5,6]"
|
||||
output: "true"
|
||||
explanation: "Every level before the last is full (levels with node-values {1} and {2, 3}), and all nodes in the last level ({4, 5, 6}) are as far left as possible."
|
||||
- input: "root = [1,2,3,4,5,null,7]"
|
||||
output: "false"
|
||||
explanation: "The node with value 7 isn't as far left as possible. There's a gap (null) before node 7 on the last level."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Think of a complete binary tree like filling seats in a theatre row by row, from left to right. You can't leave an empty seat and then have someone sit further right — that would create a "gap."
|
||||
|
||||
The key insight is this: **once you encounter a `null` (empty position) during level-order traversal, you should never see another non-null node**. If you do, there's a gap, and the tree is not complete.
|
||||
|
||||
Imagine walking through the tree level by level, left to right (BFS style). In a complete binary tree, all the nodes are "packed" to the left. The moment you see a missing child, every subsequent position in the traversal should also be empty. If a node appears after a gap, the tree fails the completeness test.
|
||||
|
||||
approach: |
|
||||
We solve this using **Breadth-First Search (BFS)** with a simple flag:
|
||||
|
||||
**Step 1: Initialise the BFS queue and a flag**
|
||||
|
||||
- `queue`: Start with the root node
|
||||
- `found_null`: Set to `False` — this tracks whether we've encountered a missing node
|
||||
|
||||
|
||||
|
||||
**Step 2: Process nodes level by level**
|
||||
|
||||
- Dequeue the front node
|
||||
- If the node is `null`, set `found_null = True` and continue
|
||||
- If the node is not `null` but `found_null` is already `True`, return `False` — we found a node after a gap
|
||||
- Add the node's left and right children to the queue (even if they're `null`)
|
||||
|
||||
|
||||
|
||||
**Step 3: Return the result**
|
||||
|
||||
- If we process all nodes without finding a node after a gap, return `True`
|
||||
|
||||
|
||||
|
||||
The BFS ensures we traverse level by level, left to right — exactly the order needed to detect gaps in a complete binary tree.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Using DFS Instead of BFS
|
||||
description: |
|
||||
Depth-first search (preorder, inorder, postorder) doesn't naturally traverse nodes in level-order. While you *can* solve this with DFS using node positions, it's more complex.
|
||||
|
||||
BFS naturally visits nodes in the exact order we need — level by level, left to right — making the "gap detection" logic straightforward.
|
||||
wrong_approach: "DFS traversal without careful position tracking"
|
||||
correct_approach: "BFS level-order traversal with null detection"
|
||||
|
||||
- title: Not Enqueueing Null Children
|
||||
description: |
|
||||
A common mistake is only adding non-null children to the queue. But to detect gaps, we need to see *where* the nulls are in the level-order sequence.
|
||||
|
||||
If we skip nulls entirely, we can't tell if a valid node appears after a gap. The queue must include null placeholders to preserve the tree's structure during traversal.
|
||||
wrong_approach: "Only add non-null children to queue"
|
||||
correct_approach: "Add both children (including nulls) to preserve position information"
|
||||
|
||||
- title: Stopping at First Null
|
||||
description: |
|
||||
Simply returning `True` when you see a null is incorrect. Seeing a null is fine — a complete tree can have nulls at the end of the last level.
|
||||
|
||||
The violation occurs only when a **non-null node appears after** a null. We need to continue processing and check for this specific condition.
|
||||
wrong_approach: "Return True immediately when null is encountered"
|
||||
correct_approach: "Set flag on null, return False only if non-null follows"
|
||||
|
||||
key_takeaways:
|
||||
- "**BFS for level-order problems**: When a problem involves tree levels or left-to-right ordering, BFS is usually the natural choice"
|
||||
- "**Gap detection pattern**: The 'flag after null' technique applies to any sequence where gaps indicate invalidity"
|
||||
- "**Include nulls in traversal**: Sometimes explicitly tracking empty positions is essential to preserve structural information"
|
||||
- "**Complete vs Full vs Perfect**: Complete trees fill left-to-right; full trees have 0 or 2 children per node; perfect trees are complete *and* full"
|
||||
|
||||
time_complexity: "O(n). We visit each node exactly once during the BFS traversal, where `n` is the number of nodes in the tree."
|
||||
space_complexity: "O(w), where `w` is the maximum width of the tree. In the worst case (a complete tree), the last level can have up to `n/2` nodes, so this is O(n)."
|
||||
|
||||
solutions:
|
||||
- approach_name: BFS with Null Flag
|
||||
is_optimal: true
|
||||
code: |
|
||||
from collections import deque
|
||||
|
||||
class TreeNode:
|
||||
def __init__(self, val=0, left=None, right=None):
|
||||
self.val = val
|
||||
self.left = left
|
||||
self.right = right
|
||||
|
||||
def is_complete_tree(root: TreeNode) -> bool:
|
||||
if not root:
|
||||
return True
|
||||
|
||||
queue = deque([root])
|
||||
# Flag to track if we've seen a null position
|
||||
found_null = False
|
||||
|
||||
while queue:
|
||||
node = queue.popleft()
|
||||
|
||||
if node is None:
|
||||
# Mark that we've encountered a gap
|
||||
found_null = True
|
||||
else:
|
||||
# If we see a node after a gap, tree is not complete
|
||||
if found_null:
|
||||
return False
|
||||
|
||||
# Add both children (even if null) to track positions
|
||||
queue.append(node.left)
|
||||
queue.append(node.right)
|
||||
|
||||
# No node appeared after a gap — tree is complete
|
||||
return True
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — We visit each of the n nodes exactly once.
|
||||
|
||||
**Space Complexity:** O(n) — The queue can hold up to n/2 nodes (the last level of a complete tree).
|
||||
|
||||
This solution uses BFS to traverse the tree level by level. The key insight is that in a complete binary tree, once we encounter a null, all subsequent nodes in the traversal must also be null. If a non-null node appears after a null, we immediately know the tree is incomplete.
|
||||
|
||||
- approach_name: BFS with Node Counting
|
||||
is_optimal: false
|
||||
code: |
|
||||
from collections import deque
|
||||
|
||||
def is_complete_tree(root: TreeNode) -> bool:
|
||||
if not root:
|
||||
return True
|
||||
|
||||
# First pass: count total nodes
|
||||
count = 0
|
||||
queue = deque([root])
|
||||
while queue:
|
||||
node = queue.popleft()
|
||||
count += 1
|
||||
if node.left:
|
||||
queue.append(node.left)
|
||||
if node.right:
|
||||
queue.append(node.right)
|
||||
|
||||
# Second pass: check if node at index i exists for all i < count
|
||||
# Using 1-based indexing: left child = 2*i, right child = 2*i+1
|
||||
queue = deque([(root, 1)]) # (node, index)
|
||||
while queue:
|
||||
node, idx = queue.popleft()
|
||||
# If index exceeds count, tree is not complete
|
||||
if idx > count:
|
||||
return False
|
||||
if node.left:
|
||||
queue.append((node.left, 2 * idx))
|
||||
if node.right:
|
||||
queue.append((node.right, 2 * idx + 1))
|
||||
|
||||
return True
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Two passes through the tree.
|
||||
|
||||
**Space Complexity:** O(n) — Queue storage for BFS.
|
||||
|
||||
This approach uses the property that in a complete binary tree with n nodes, if we assign indices 1 to n in level-order, every index from 1 to n must be filled. We first count nodes, then verify no node has an index greater than the count. While correct, this requires two passes and is slightly less elegant than the null-flag approach.
|
||||
168
backend/data/questions/check-distances-between-same-letters.yaml
Normal file
168
backend/data/questions/check-distances-between-same-letters.yaml
Normal file
@@ -0,0 +1,168 @@
|
||||
title: Check Distances Between Same Letters
|
||||
slug: check-distances-between-same-letters
|
||||
difficulty: easy
|
||||
leetcode_id: 2399
|
||||
leetcode_url: https://leetcode.com/problems/check-distances-between-same-letters/
|
||||
categories:
|
||||
- arrays
|
||||
- strings
|
||||
- hash-tables
|
||||
patterns:
|
||||
- two-pointers
|
||||
|
||||
description: |
|
||||
You are given a **0-indexed** string `s` consisting of only lowercase English letters, where each letter in `s` appears **exactly twice**. You are also given a **0-indexed** integer array `distance` of length `26`.
|
||||
|
||||
Each letter in the alphabet is numbered from `0` to `25` (i.e. `'a' -> 0`, `'b' -> 1`, `'c' -> 2`, ... , `'z' -> 25`).
|
||||
|
||||
In a **well-spaced** string, the number of letters between the two occurrences of the i<sup>th</sup> letter is `distance[i]`. If the i<sup>th</sup> letter does not appear in `s`, then `distance[i]` can be **ignored**.
|
||||
|
||||
Return `true` *if* `s` *is a **well-spaced** string, otherwise return* `false`.
|
||||
|
||||
constraints: |
|
||||
- `2 <= s.length <= 52`
|
||||
- `s` consists only of lowercase English letters
|
||||
- Each letter appears in `s` exactly twice
|
||||
- `distance.length == 26`
|
||||
- `0 <= distance[i] <= 50`
|
||||
|
||||
examples:
|
||||
- input: 's = "abaccb", distance = [1,3,0,5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]'
|
||||
output: "true"
|
||||
explanation: "'a' appears at indices 0 and 2 (distance 1). 'b' appears at indices 1 and 5 (distance 3). 'c' appears at indices 3 and 4 (distance 0). All letters satisfy their required distances."
|
||||
- input: 's = "aa", distance = [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]'
|
||||
output: "false"
|
||||
explanation: "'a' appears at indices 0 and 1, so there are zero letters between them. Since distance[0] = 1, the string is not well-spaced."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Think of this problem as a **verification task**: for each letter in the string, we need to confirm that the gap between its two occurrences matches the expected distance.
|
||||
|
||||
Imagine walking through the string character by character. The first time you encounter a letter, you note its position. The second time you see that same letter, you calculate how many characters are *between* the two positions and check if it matches the required distance.
|
||||
|
||||
The key insight is that the "distance" is the number of characters *between* the two occurrences, not the difference in indices. If a letter appears at indices `i` and `j` (where `j > i`), the number of letters between them is `j - i - 1`.
|
||||
|
||||
For example, if `'a'` appears at index 0 and index 2, there's exactly 1 character between them (at index 1), so the distance is `2 - 0 - 1 = 1`.
|
||||
|
||||
approach: |
|
||||
We solve this using a **single pass with position tracking**:
|
||||
|
||||
**Step 1: Create a position tracker**
|
||||
|
||||
- Use an array or dictionary to store the first occurrence index of each letter
|
||||
- Initialise all positions to `-1` (indicating "not yet seen")
|
||||
|
||||
|
||||
|
||||
**Step 2: Iterate through the string**
|
||||
|
||||
- For each character at index `i`:
|
||||
- Get the letter's alphabet index: `char_index = ord(c) - ord('a')`
|
||||
- If this is the first occurrence (position is `-1`), store the current index
|
||||
- If this is the second occurrence, calculate the actual distance: `i - first_index - 1`
|
||||
- Compare with the expected distance from `distance[char_index]`
|
||||
- If they don't match, return `false` immediately
|
||||
|
||||
|
||||
|
||||
**Step 3: Return the result**
|
||||
|
||||
- If we complete the loop without finding any mismatches, return `true`
|
||||
|
||||
|
||||
|
||||
This approach works because we process each character exactly once, and by the time we see the second occurrence, we have all the information needed to verify the distance constraint.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Off-by-One Error in Distance Calculation
|
||||
description: |
|
||||
A common mistake is calculating the distance as `j - i` instead of `j - i - 1`.
|
||||
|
||||
The problem asks for the number of letters *between* the two occurrences, not the difference in indices. If `'a'` is at index 0 and index 2, the indices differ by 2, but there's only 1 letter between them (at index 1).
|
||||
|
||||
Always remember: **distance = second_index - first_index - 1**
|
||||
wrong_approach: "Using j - i as the distance"
|
||||
correct_approach: "Using j - i - 1 to count characters between positions"
|
||||
|
||||
- title: Forgetting to Handle the Alphabet Index
|
||||
description: |
|
||||
Each letter maps to an index in the `distance` array: `'a' -> 0`, `'b' -> 1`, etc.
|
||||
|
||||
To convert a character to its alphabet index, use: `ord(c) - ord('a')` in Python or `c.charCodeAt(0) - 97` in JavaScript.
|
||||
|
||||
Forgetting this mapping will cause incorrect lookups in the distance array.
|
||||
wrong_approach: "Using the character directly as an index"
|
||||
correct_approach: "Converting character to 0-25 index using ASCII arithmetic"
|
||||
|
||||
- title: Checking All 26 Letters
|
||||
description: |
|
||||
Don't iterate through all 26 letters checking if they exist in the string. The problem states that unused letters in `distance` can be ignored.
|
||||
|
||||
It's more efficient to iterate through the string once and only check letters that actually appear. This gives O(n) time where n is the string length, rather than potentially checking letters that don't exist.
|
||||
wrong_approach: "Iterating through alphabet and searching for each letter"
|
||||
correct_approach: "Iterating through string and tracking first occurrences"
|
||||
|
||||
key_takeaways:
|
||||
- "**Position tracking pattern**: Store first occurrence positions to compute distances when second occurrence is found"
|
||||
- "**Early termination**: Return `false` immediately when a mismatch is found — no need to check remaining characters"
|
||||
- "**ASCII arithmetic**: Converting characters to array indices with `ord(c) - ord('a')` is a common technique for alphabet-indexed problems"
|
||||
- "**Verification vs construction**: This is a verification problem — we're checking a property, not building something"
|
||||
|
||||
time_complexity: "O(n). We iterate through the string once, where `n` is the length of `s`. Each character is processed in O(1) time."
|
||||
space_complexity: "O(1). We use an array of size 26 to track first occurrences, which is constant regardless of input size."
|
||||
|
||||
solutions:
|
||||
- approach_name: Single Pass with Position Tracking
|
||||
is_optimal: true
|
||||
code: |
|
||||
def check_distances(s: str, distance: list[int]) -> bool:
|
||||
# Track first occurrence index of each letter (-1 means not seen)
|
||||
first_occurrence = [-1] * 26
|
||||
|
||||
for i, c in enumerate(s):
|
||||
# Convert character to alphabet index (a=0, b=1, ..., z=25)
|
||||
char_index = ord(c) - ord('a')
|
||||
|
||||
if first_occurrence[char_index] == -1:
|
||||
# First time seeing this letter, store its position
|
||||
first_occurrence[char_index] = i
|
||||
else:
|
||||
# Second occurrence: check if distance matches
|
||||
actual_distance = i - first_occurrence[char_index] - 1
|
||||
if actual_distance != distance[char_index]:
|
||||
return False
|
||||
|
||||
# All letters satisfied their distance requirements
|
||||
return True
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Single pass through the string.
|
||||
|
||||
**Space Complexity:** O(1) — Fixed array of 26 elements for tracking.
|
||||
|
||||
We iterate through each character, storing first occurrence positions and verifying distances on second occurrences. Early termination on mismatch makes this efficient for invalid strings.
|
||||
|
||||
- approach_name: Hash Map Approach
|
||||
is_optimal: false
|
||||
code: |
|
||||
def check_distances(s: str, distance: list[int]) -> bool:
|
||||
# Dictionary to store first occurrence index
|
||||
first_seen = {}
|
||||
|
||||
for i, c in enumerate(s):
|
||||
if c not in first_seen:
|
||||
# First occurrence: record position
|
||||
first_seen[c] = i
|
||||
else:
|
||||
# Second occurrence: verify distance
|
||||
actual_distance = i - first_seen[c] - 1
|
||||
expected_distance = distance[ord(c) - ord('a')]
|
||||
if actual_distance != expected_distance:
|
||||
return False
|
||||
|
||||
return True
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Single pass through the string.
|
||||
|
||||
**Space Complexity:** O(k) — Where k is the number of unique letters (at most 26).
|
||||
|
||||
This approach uses a dictionary instead of a fixed-size array. While asymptotically equivalent, the array approach has slightly better constant factors due to direct indexing. The dictionary approach is more intuitive and works well when the character set is unknown or sparse.
|
||||
@@ -0,0 +1,280 @@
|
||||
title: Check if a Parentheses String Can Be Valid
|
||||
slug: check-if-a-parentheses-string-can-be-valid
|
||||
difficulty: medium
|
||||
leetcode_id: 2116
|
||||
leetcode_url: https://leetcode.com/problems/check-if-a-parentheses-string-can-be-valid/
|
||||
categories:
|
||||
- strings
|
||||
- stack
|
||||
patterns:
|
||||
- greedy
|
||||
|
||||
description: |
|
||||
A parentheses string is a **non-empty** string consisting only of `'('` and `')'`. It is valid if **any** of the following conditions is **true**:
|
||||
|
||||
- It is `()`.
|
||||
- It can be written as `AB` (`A` concatenated with `B`), where `A` and `B` are valid parentheses strings.
|
||||
- It can be written as `(A)`, where `A` is a valid parentheses string.
|
||||
|
||||
You are given a parentheses string `s` and a string `locked`, both of length `n`. `locked` is a binary string consisting only of `'0'`s and `'1'`s. For **each** index `i` of `locked`:
|
||||
|
||||
- If `locked[i]` is `'1'`, you **cannot** change `s[i]`.
|
||||
- But if `locked[i]` is `'0'`, you **can** change `s[i]` to either `'('` or `')'`.
|
||||
|
||||
Return `true` *if you can make `s` a valid parentheses string*. Otherwise, return `false`.
|
||||
|
||||
constraints: |
|
||||
- `n == s.length == locked.length`
|
||||
- `1 <= n <= 10^5`
|
||||
- `s[i]` is either `'('` or `')'`
|
||||
- `locked[i]` is either `'0'` or `'1'`
|
||||
|
||||
examples:
|
||||
- input: 's = "))()))", locked = "010100"'
|
||||
output: "true"
|
||||
explanation: "locked[1] == '1' and locked[3] == '1', so we cannot change s[1] or s[3]. We change s[0] and s[4] to '(' while leaving s[2] and s[5] unchanged to make s valid."
|
||||
- input: 's = "()()", locked = "0000"'
|
||||
output: "true"
|
||||
explanation: "We do not need to make any changes because s is already valid."
|
||||
- input: 's = ")", locked = "0"'
|
||||
output: "false"
|
||||
explanation: "locked permits us to change s[0]. Changing s[0] to either '(' or ')' will not make s valid."
|
||||
- input: 's = "(((())(((())", locked = "111111010111"'
|
||||
output: "true"
|
||||
explanation: "locked permits us to change s[6] and s[8]. We change s[6] and s[8] to ')' to make s valid."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Think of parentheses validation like a balance scale. Each `'('` adds weight to one side, and each `')'` removes it. For the string to be valid, the scale must never tip negative (more closing than opening) at any point, and must end perfectly balanced.
|
||||
|
||||
The twist here is that some characters are "wild" (unlocked) — they can become either `'('` or `')'`. This gives us flexibility, but we need to use it wisely.
|
||||
|
||||
The key insight is to think in terms of **ranges** rather than exact counts. At any position, we don't need to know the *exact* balance — we need to know the *possible range* of balances. An unlocked character contributes uncertainty: it could add +1 (if we treat it as `'('`) or -1 (if we treat it as `')'`).
|
||||
|
||||
By tracking the minimum and maximum possible balance as we scan left-to-right, we can determine if there's *any* valid assignment. If our minimum ever exceeds our maximum (impossible range), or if we can't reach exactly zero balance at the end, the answer is false.
|
||||
|
||||
approach: |
|
||||
We use a **Two-Pass Greedy** approach, checking validity from both directions:
|
||||
|
||||
**Step 1: Check the length parity**
|
||||
|
||||
- If the string length is odd, return `false` immediately — valid parentheses always have even length
|
||||
|
||||
|
||||
|
||||
**Step 2: Left-to-right pass (check for excess closing brackets)**
|
||||
|
||||
- Track `balance`: increment for `'('`, decrement for `')'`
|
||||
- Track `wildcards`: count of unlocked positions we can use as `'('`
|
||||
- For each locked `')'`, we need either a prior `'('` or an available wildcard
|
||||
- If `balance + wildcards < 0`, we have too many unmatched `')'` — return `false`
|
||||
|
||||
|
||||
|
||||
**Step 3: Right-to-left pass (check for excess opening brackets)**
|
||||
|
||||
- Reset and scan from right to left
|
||||
- Now track closing brackets `')'` and wildcards that could become `')'`
|
||||
- For each locked `'('`, we need either a later `')'` or an available wildcard
|
||||
- If `balance + wildcards < 0`, we have too many unmatched `'('` — return `false`
|
||||
|
||||
|
||||
|
||||
**Step 4: Return the result**
|
||||
|
||||
- If both passes succeed, return `true` — a valid assignment exists
|
||||
|
||||
|
||||
|
||||
The two-pass approach works because the left-to-right pass ensures we never have excess closing brackets, while the right-to-left pass ensures we never have excess opening brackets.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Single Pass Insufficient
|
||||
description: |
|
||||
A common mistake is attempting a single left-to-right pass. While this detects excess `')'` brackets, it fails to catch excess `'('` brackets that have no matching `')'` later.
|
||||
|
||||
For example, `s = "((("` with `locked = "111"` would pass a left-only check but is clearly invalid.
|
||||
wrong_approach: "Single left-to-right pass only"
|
||||
correct_approach: "Two passes: left-to-right AND right-to-left"
|
||||
|
||||
- title: Forgetting the Odd Length Check
|
||||
description: |
|
||||
Valid parentheses strings must have even length — each `'('` needs exactly one matching `')'`. If `n` is odd, no amount of character swapping can make it valid.
|
||||
|
||||
Checking this upfront saves computation and handles edge cases cleanly.
|
||||
wrong_approach: "Proceeding without checking length parity"
|
||||
correct_approach: "Return false immediately if length is odd"
|
||||
|
||||
- title: Treating Wildcards as Both Simultaneously
|
||||
description: |
|
||||
An unlocked position can be `'('` OR `')'`, not both. Some approaches incorrectly count wildcards twice, leading to false positives.
|
||||
|
||||
Each wildcard can only satisfy one need — either an unmatched `'('` or an unmatched `')'`, but not both in the same validation.
|
||||
wrong_approach: "Counting wildcards for both opening and closing"
|
||||
correct_approach: "Track wildcards separately, using them greedily in each pass"
|
||||
|
||||
key_takeaways:
|
||||
- "**Two-pass validation**: When checking constraints from both ends, separate passes can be cleaner than tracking complex ranges"
|
||||
- "**Greedy wildcard usage**: Treat flexible elements as satisfying the most urgent need at each step"
|
||||
- "**Parity check first**: Simple invariants (like even length) can eliminate impossible cases early"
|
||||
- "**Extend to similar problems**: This pattern applies to any matching problem with flexible elements"
|
||||
|
||||
time_complexity: "O(n). We traverse the string exactly twice — once left-to-right and once right-to-left."
|
||||
space_complexity: "O(1). We only use a constant number of variables (`balance`, `wildcards`) regardless of input size."
|
||||
|
||||
solutions:
|
||||
- approach_name: Two-Pass Greedy
|
||||
is_optimal: true
|
||||
code: |
|
||||
def can_be_valid(s: str, locked: str) -> bool:
|
||||
n = len(s)
|
||||
|
||||
# Valid parentheses must have even length
|
||||
if n % 2 == 1:
|
||||
return False
|
||||
|
||||
# Left-to-right: ensure we never have excess ')'
|
||||
balance = 0 # Count of unmatched '('
|
||||
wildcards = 0 # Unlocked chars that could become '('
|
||||
|
||||
for i in range(n):
|
||||
if locked[i] == '0':
|
||||
# Unlocked: could be either, save as potential '('
|
||||
wildcards += 1
|
||||
elif s[i] == '(':
|
||||
# Locked '(': adds to balance
|
||||
balance += 1
|
||||
else:
|
||||
# Locked ')': needs matching '(' or wildcard
|
||||
if balance > 0:
|
||||
balance -= 1
|
||||
elif wildcards > 0:
|
||||
wildcards -= 1
|
||||
else:
|
||||
# No way to match this ')'
|
||||
return False
|
||||
|
||||
# Right-to-left: ensure we never have excess '('
|
||||
balance = 0 # Count of unmatched ')'
|
||||
wildcards = 0 # Unlocked chars that could become ')'
|
||||
|
||||
for i in range(n - 1, -1, -1):
|
||||
if locked[i] == '0':
|
||||
# Unlocked: could be either, save as potential ')'
|
||||
wildcards += 1
|
||||
elif s[i] == ')':
|
||||
# Locked ')': adds to balance
|
||||
balance += 1
|
||||
else:
|
||||
# Locked '(': needs matching ')' or wildcard
|
||||
if balance > 0:
|
||||
balance -= 1
|
||||
elif wildcards > 0:
|
||||
wildcards -= 1
|
||||
else:
|
||||
# No way to match this '('
|
||||
return False
|
||||
|
||||
return True
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Two linear passes through the string.
|
||||
|
||||
**Space Complexity:** O(1) — Only constant extra space used.
|
||||
|
||||
The two-pass approach leverages greedy matching: in the left-to-right pass, we ensure every locked `')'` can be matched by a prior `'('` or wildcard. In the right-to-left pass, we ensure every locked `'('` can be matched by a later `')'` or wildcard. If both constraints are satisfiable, a valid assignment exists.
|
||||
|
||||
- approach_name: Range Tracking (Single Pass)
|
||||
is_optimal: true
|
||||
code: |
|
||||
def can_be_valid(s: str, locked: str) -> bool:
|
||||
n = len(s)
|
||||
|
||||
# Valid parentheses must have even length
|
||||
if n % 2 == 1:
|
||||
return False
|
||||
|
||||
# Track the range of possible balances
|
||||
min_balance = 0 # Minimum possible open count
|
||||
max_balance = 0 # Maximum possible open count
|
||||
|
||||
for i in range(n):
|
||||
if locked[i] == '0':
|
||||
# Unlocked: could be '(' (+1) or ')' (-1)
|
||||
min_balance -= 1
|
||||
max_balance += 1
|
||||
elif s[i] == '(':
|
||||
# Locked '(': must add 1
|
||||
min_balance += 1
|
||||
max_balance += 1
|
||||
else:
|
||||
# Locked ')': must subtract 1
|
||||
min_balance -= 1
|
||||
max_balance -= 1
|
||||
|
||||
# Can't have negative balance (more ')' than '(')
|
||||
if max_balance < 0:
|
||||
return False
|
||||
|
||||
# Keep min_balance non-negative (we choose '(' for wildcards when needed)
|
||||
min_balance = max(min_balance, 0)
|
||||
|
||||
# Must be able to reach exactly 0 balance
|
||||
return min_balance == 0
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Single pass through the string.
|
||||
|
||||
**Space Complexity:** O(1) — Only two variables for tracking range.
|
||||
|
||||
This elegant approach tracks the *range* of possible balances rather than a single value. Unlocked characters expand the range (could be +1 or -1), while locked characters shift it. If `max_balance` ever goes negative, even the most optimistic assignment fails. If `min_balance` can reach 0 at the end, a valid assignment exists.
|
||||
|
||||
- approach_name: Stack-Based Simulation
|
||||
is_optimal: false
|
||||
code: |
|
||||
def can_be_valid(s: str, locked: str) -> bool:
|
||||
n = len(s)
|
||||
|
||||
# Valid parentheses must have even length
|
||||
if n % 2 == 1:
|
||||
return False
|
||||
|
||||
# Stacks to track indices
|
||||
locked_opens = [] # Indices of locked '('
|
||||
wildcards = [] # Indices of unlocked characters
|
||||
|
||||
for i in range(n):
|
||||
if locked[i] == '0':
|
||||
# Unlocked character
|
||||
wildcards.append(i)
|
||||
elif s[i] == '(':
|
||||
# Locked '('
|
||||
locked_opens.append(i)
|
||||
else:
|
||||
# Locked ')': match with '(' or wildcard
|
||||
if locked_opens:
|
||||
locked_opens.pop()
|
||||
elif wildcards:
|
||||
wildcards.pop()
|
||||
else:
|
||||
return False
|
||||
|
||||
# Match remaining locked '(' with wildcards
|
||||
while locked_opens and wildcards:
|
||||
# Wildcard must come AFTER the '(' to act as ')'
|
||||
if locked_opens[-1] < wildcards[-1]:
|
||||
locked_opens.pop()
|
||||
wildcards.pop()
|
||||
else:
|
||||
return False
|
||||
|
||||
# Any remaining locked '(' can't be matched
|
||||
if locked_opens:
|
||||
return False
|
||||
|
||||
# Remaining wildcards must pair with each other (even count)
|
||||
return len(wildcards) % 2 == 0
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Single pass to process, plus cleanup of stacks.
|
||||
|
||||
**Space Complexity:** O(n) — Stacks could hold up to n/2 indices each.
|
||||
|
||||
This approach explicitly tracks positions using stacks. While correct, it uses more space than necessary. The stack-based method is useful for understanding the matching process but the range-tracking approach is more elegant for this problem.
|
||||
@@ -0,0 +1,181 @@
|
||||
title: Check If a String Can Break Another String
|
||||
slug: check-if-a-string-can-break-another-string
|
||||
difficulty: medium
|
||||
leetcode_id: 1433
|
||||
leetcode_url: https://leetcode.com/problems/check-if-a-string-can-break-another-string/
|
||||
categories:
|
||||
- strings
|
||||
- sorting
|
||||
patterns:
|
||||
- greedy
|
||||
|
||||
description: |
|
||||
Given two strings: `s1` and `s2` with the same size, check if some permutation of string `s1` can break some permutation of string `s2` or vice-versa. In other words `s2` can break `s1` or vice-versa.
|
||||
|
||||
A string `x` can *break* string `y` (both of size `n`) if `x[i] >= y[i]` (in alphabetical order) for all `i` between `0` and `n-1`.
|
||||
|
||||
constraints: |
|
||||
- `s1.length == n`
|
||||
- `s2.length == n`
|
||||
- `1 <= n <= 10^5`
|
||||
- All strings consist of lowercase English letters.
|
||||
|
||||
examples:
|
||||
- input: 's1 = "abc", s2 = "xya"'
|
||||
output: "true"
|
||||
explanation: '"ayx" is a permutation of s2="xya" which can break string "abc" which is a permutation of s1="abc".'
|
||||
- input: 's1 = "abe", s2 = "acd"'
|
||||
output: "false"
|
||||
explanation: 'All permutations for s1="abe" are: "abe", "aeb", "bae", "bea", "eab" and "eba" and all permutations for s2="acd" are: "acd", "adc", "cad", "cda", "dac" and "dca". However, there is no permutation from s1 which can break some permutation from s2 and vice-versa.'
|
||||
- input: 's1 = "leetcodee", s2 = "interview"'
|
||||
output: "true"
|
||||
explanation: "After sorting both strings, we can check that one can break the other."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Think of this problem like a head-to-head competition between two teams of characters.
|
||||
|
||||
Each character in one string needs to "beat" (or at least tie with) the corresponding character in the other string. The key insight is that we have the freedom to **rearrange both strings** in any order we like before comparing.
|
||||
|
||||
Imagine you're a coach arranging a lineup for a match. You want to pair your strongest players against their weakest, your second-strongest against their second-weakest, and so on. This is exactly what sorting achieves: it aligns the "strengths" (alphabetical values) of characters in an optimal way.
|
||||
|
||||
If we sort both strings, we get the best possible matchup for determining if one can break the other. After sorting, if string `x` can break string `y`, it means character-by-character from the smallest to the largest, `x` always has an equal or greater character. If neither direction works after sorting, then no permutation will work.
|
||||
|
||||
approach: |
|
||||
We solve this using a **Sort and Compare** approach:
|
||||
|
||||
**Step 1: Sort both strings**
|
||||
|
||||
- Convert each string to a list of characters and sort them
|
||||
- This arranges characters from smallest ('a') to largest ('z')
|
||||
- Sorting gives us the optimal alignment for comparison
|
||||
|
||||
|
||||
|
||||
**Step 2: Check if s1 can break s2**
|
||||
|
||||
- Compare characters position by position
|
||||
- For s1 to break s2, every character in sorted s1 must be >= the corresponding character in sorted s2
|
||||
- Track this with a simple loop and a boolean flag
|
||||
|
||||
|
||||
|
||||
**Step 3: Check if s2 can break s1**
|
||||
|
||||
- Similarly, check if every character in sorted s2 is >= the corresponding character in sorted s1
|
||||
- We can do both checks in a single pass by tracking two conditions
|
||||
|
||||
|
||||
|
||||
**Step 4: Return result**
|
||||
|
||||
- Return `True` if either direction works (s1 breaks s2 OR s2 breaks s1)
|
||||
- Return `False` only if neither direction is possible
|
||||
|
||||
|
||||
|
||||
The greedy insight is that sorting gives the optimal pairing. If any permutation allows breaking, the sorted permutation will also allow it.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Checking All Permutations
|
||||
description: |
|
||||
A brute-force approach would generate all permutations of both strings and check every pair. With `n` characters, there are `n!` permutations for each string, leading to O(n! * n!) comparisons.
|
||||
|
||||
For `n = 10`, this is already `3,628,800 * 3,628,800` pairs. With `n <= 10^5`, this is completely infeasible.
|
||||
|
||||
The key insight is that sorting both strings gives the optimal comparison, so we only need to check sorted strings.
|
||||
wrong_approach: "Enumerate all n! permutations of each string"
|
||||
correct_approach: "Sort both strings once and compare directly"
|
||||
|
||||
- title: Checking Only One Direction
|
||||
description: |
|
||||
The problem asks if s1 can break s2 **OR** s2 can break s1. A common mistake is only checking one direction and returning `False` prematurely.
|
||||
|
||||
For example, with `s1 = "abc"` and `s2 = "xya"`, s1 cannot break s2 (after sorting: "abc" vs "axy", and 'b' < 'x'), but s2 CAN break s1 ("axy" >= "abc" at every position).
|
||||
|
||||
Always check both directions before returning `False`.
|
||||
wrong_approach: "Check only if s1 breaks s2"
|
||||
correct_approach: "Check both directions and return True if either works"
|
||||
|
||||
- title: Forgetting Equality Is Allowed
|
||||
description: |
|
||||
The definition states `x[i] >= y[i]`, not `x[i] > y[i]`. Equal characters satisfy the breaking condition.
|
||||
|
||||
For `s1 = "aaa"` and `s2 = "aaa"`, both strings can break each other since all positions have equal characters.
|
||||
|
||||
Make sure to use `>=` in your comparisons, not `>`.
|
||||
|
||||
key_takeaways:
|
||||
- "**Sorting optimises permutation problems**: When you can rearrange elements freely, sorting often reveals the optimal configuration"
|
||||
- "**Greedy alignment**: Matching smallest-to-smallest and largest-to-largest gives the best chance for one sequence to dominate another"
|
||||
- "**Check both directions**: Bidirectional conditions require testing both possibilities before concluding failure"
|
||||
- "**Reduce complexity through insight**: This problem appears exponential (permutations) but is actually O(n log n) with the sorting insight"
|
||||
|
||||
time_complexity: "O(n log n). Sorting both strings dominates the runtime, where `n` is the string length. The comparison pass is O(n)."
|
||||
space_complexity: "O(n). We need to store the sorted versions of both strings. In Python, sorting a string requires converting to a list first."
|
||||
|
||||
solutions:
|
||||
- approach_name: Sort and Compare
|
||||
is_optimal: true
|
||||
code: |
|
||||
def check_if_can_break(s1: str, s2: str) -> bool:
|
||||
# Sort both strings to get optimal character alignment
|
||||
sorted_s1 = sorted(s1)
|
||||
sorted_s2 = sorted(s2)
|
||||
|
||||
# Track if s1 can break s2 and if s2 can break s1
|
||||
s1_breaks_s2 = True
|
||||
s2_breaks_s1 = True
|
||||
|
||||
# Compare character by character
|
||||
for c1, c2 in zip(sorted_s1, sorted_s2):
|
||||
# If any position fails, that direction can't break
|
||||
if c1 < c2:
|
||||
s1_breaks_s2 = False
|
||||
if c2 < c1:
|
||||
s2_breaks_s1 = False
|
||||
|
||||
# Early exit if neither direction is possible
|
||||
if not s1_breaks_s2 and not s2_breaks_s1:
|
||||
return False
|
||||
|
||||
# Return True if either direction works
|
||||
return s1_breaks_s2 or s2_breaks_s1
|
||||
explanation: |
|
||||
**Time Complexity:** O(n log n) — Sorting dominates; the comparison loop is O(n).
|
||||
|
||||
**Space Complexity:** O(n) — Storage for sorted character lists.
|
||||
|
||||
We sort both strings and compare them position by position. By sorting, we ensure the smallest characters are compared first, which is the optimal alignment for determining if one string can break another. We check both directions simultaneously and return early if we find that neither is possible.
|
||||
|
||||
- approach_name: Counting Sort Optimisation
|
||||
is_optimal: true
|
||||
code: |
|
||||
def check_if_can_break(s1: str, s2: str) -> bool:
|
||||
# Count character frequencies (26 lowercase letters)
|
||||
count1 = [0] * 26
|
||||
count2 = [0] * 26
|
||||
|
||||
for c in s1:
|
||||
count1[ord(c) - ord('a')] += 1
|
||||
for c in s2:
|
||||
count2[ord(c) - ord('a')] += 1
|
||||
|
||||
# Build sorted strings from counts
|
||||
sorted_s1 = []
|
||||
sorted_s2 = []
|
||||
for i in range(26):
|
||||
sorted_s1.extend([chr(i + ord('a'))] * count1[i])
|
||||
sorted_s2.extend([chr(i + ord('a'))] * count2[i])
|
||||
|
||||
# Check both directions
|
||||
s1_breaks_s2 = all(c1 >= c2 for c1, c2 in zip(sorted_s1, sorted_s2))
|
||||
s2_breaks_s1 = all(c2 >= c1 for c1, c2 in zip(sorted_s1, sorted_s2))
|
||||
|
||||
return s1_breaks_s2 or s2_breaks_s1
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Counting and building sorted arrays is linear.
|
||||
|
||||
**Space Complexity:** O(n) — Storage for sorted character arrays.
|
||||
|
||||
Since we're dealing with lowercase English letters only (26 characters), we can use counting sort for O(n) time instead of O(n log n). We count the frequency of each character, then reconstruct sorted strings from the counts. This is faster for large inputs but the difference is often negligible in practice.
|
||||
@@ -0,0 +1,214 @@
|
||||
title: Check If a String Contains All Binary Codes of Size K
|
||||
slug: check-if-a-string-contains-all-binary-codes-of-size-k
|
||||
difficulty: medium
|
||||
leetcode_id: 1461
|
||||
leetcode_url: https://leetcode.com/problems/check-if-a-string-contains-all-binary-codes-of-size-k/
|
||||
categories:
|
||||
- strings
|
||||
- hash-tables
|
||||
patterns:
|
||||
- sliding-window
|
||||
|
||||
description: |
|
||||
Given a binary string `s` and an integer `k`, return `true` *if every binary code of length* `k` *is a substring of* `s`. Otherwise, return `false`.
|
||||
|
||||
A binary code of length `k` is any string consisting of exactly `k` characters, where each character is either `'0'` or `'1'`. For example, when `k = 2`, all possible binary codes are: `"00"`, `"01"`, `"10"`, and `"11"`.
|
||||
|
||||
You need to verify that **all** `2^k` possible binary codes appear somewhere in `s` as contiguous substrings.
|
||||
|
||||
constraints: |
|
||||
- `1 <= s.length <= 5 * 10^5`
|
||||
- `s[i]` is either `'0'` or `'1'`
|
||||
- `1 <= k <= 20`
|
||||
|
||||
examples:
|
||||
- input: 's = "00110110", k = 2'
|
||||
output: "true"
|
||||
explanation: "The binary codes of length 2 are \"00\", \"01\", \"10\" and \"11\". They can be all found as substrings at indices 0, 1, 3 and 2 respectively."
|
||||
- input: 's = "0110", k = 1'
|
||||
output: "true"
|
||||
explanation: "The binary codes of length 1 are \"0\" and \"1\", it is clear that both exist as a substring."
|
||||
- input: 's = "0110", k = 2'
|
||||
output: "false"
|
||||
explanation: 'The binary code "00" is of length 2 and does not exist in the string.'
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Think of this problem like checking off items on a checklist. For a given `k`, there are exactly `2^k` unique binary codes (just like how there are 4 two-digit binary numbers: 00, 01, 10, 11).
|
||||
|
||||
As you slide through the string `s` with a window of size `k`, each window gives you one binary code substring. The question becomes: do you encounter **all** `2^k` possible codes while sliding through?
|
||||
|
||||
Imagine you have a box of crayons where each crayon represents a unique binary code. As you slide through the string, every window "touches" one crayon. If by the end you've touched all crayons in the box, you return `true`.
|
||||
|
||||
The key insight is that instead of generating all `2^k` codes and checking if each exists in `s` (which would be expensive), you simply **collect all unique substrings of length `k`** from `s` and check if you collected exactly `2^k` of them. A hash set naturally handles the uniqueness for you.
|
||||
|
||||
approach: |
|
||||
We use a **Sliding Window with Hash Set** approach:
|
||||
|
||||
**Step 1: Calculate the target count**
|
||||
|
||||
- Compute `required = 2^k`, which is the total number of unique binary codes of length `k`
|
||||
- If `s` is too short to even contain `required` substrings, we can return `false` early
|
||||
|
||||
|
||||
|
||||
**Step 2: Early termination check**
|
||||
|
||||
- The number of substrings of length `k` in `s` is `len(s) - k + 1`
|
||||
- If `len(s) - k + 1 < required`, it's impossible to have all codes, so return `false`
|
||||
|
||||
|
||||
|
||||
**Step 3: Slide through and collect unique substrings**
|
||||
|
||||
- Create an empty hash set to store unique binary codes
|
||||
- Iterate through `s` with a sliding window of size `k`
|
||||
- For each position `i` from `0` to `len(s) - k`, extract the substring `s[i:i+k]`
|
||||
- Add each substring to the set (duplicates are automatically ignored)
|
||||
|
||||
|
||||
|
||||
**Step 4: Compare the count**
|
||||
|
||||
- If the size of the set equals `required` (`2^k`), return `true`
|
||||
- Otherwise, return `false`
|
||||
|
||||
|
||||
|
||||
This approach works because a set only keeps unique elements. If we've seen all `2^k` unique codes, the set size will be exactly `2^k`.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Generating All Codes First
|
||||
description: |
|
||||
A tempting approach is to first generate all `2^k` binary codes, then check if each one exists in `s` using string searching.
|
||||
|
||||
This is inefficient for two reasons:
|
||||
1. Generating all codes takes O(k * 2^k) time
|
||||
2. Searching for each code in `s` takes O(n) per code, leading to O(n * 2^k) total
|
||||
|
||||
With `k = 20`, you'd have over 1 million codes to generate and search for!
|
||||
|
||||
The sliding window approach is O(n * k) instead, much better for large inputs.
|
||||
wrong_approach: "Generate all 2^k codes, search for each in s"
|
||||
correct_approach: "Collect unique substrings from s, count them"
|
||||
|
||||
- title: Off-by-One in Window Iteration
|
||||
description: |
|
||||
When iterating to extract substrings of length `k`, the loop should run from index `0` to `len(s) - k` (inclusive).
|
||||
|
||||
A common mistake is iterating to `len(s) - k + 1` or `len(s)`, which either causes index out of bounds or misses the last valid window.
|
||||
|
||||
For `s = "0110"` with `k = 2`:
|
||||
- Valid indices: 0, 1, 2 (giving "01", "11", "10")
|
||||
- Loop should be `for i in range(len(s) - k + 1)` or `for i in range(3)`
|
||||
wrong_approach: "range(len(s)) or range(len(s) - k)"
|
||||
correct_approach: "range(len(s) - k + 1)"
|
||||
|
||||
- title: Forgetting the Early Return Optimisation
|
||||
description: |
|
||||
While not strictly a bug, failing to add the early termination check can hurt performance.
|
||||
|
||||
If `len(s) < k`, there are zero substrings of length `k`. If `len(s) - k + 1 < 2^k`, it's mathematically impossible to have all codes.
|
||||
|
||||
Example: For `k = 20`, you need at least `2^20 = 1,048,576` substrings, meaning `s` must have length at least `1,048,595`.
|
||||
wrong_approach: "Always iterate through the entire string"
|
||||
correct_approach: "Check if len(s) - k + 1 >= 2^k before iterating"
|
||||
|
||||
key_takeaways:
|
||||
- "**Hash sets for counting unique items**: When you need to count distinct elements, a set automatically handles duplicates"
|
||||
- "**Sliding window for substrings**: Extracting all substrings of a fixed length is a classic sliding window pattern"
|
||||
- "**Think about the inverse**: Instead of checking if all codes exist, collect what exists and compare the count"
|
||||
- "**Early termination**: Mathematical bounds can save computation - if there aren't enough windows, the answer is definitely `false`"
|
||||
|
||||
time_complexity: "O(n * k). We slide through the string once (O(n) positions), and at each position we extract a substring of length k (O(k) for hashing/copying)."
|
||||
space_complexity: "O(2^k * k). In the worst case, the set stores all 2^k unique binary codes, each of length k characters."
|
||||
|
||||
solutions:
|
||||
- approach_name: Sliding Window with Hash Set
|
||||
is_optimal: true
|
||||
code: |
|
||||
def has_all_codes(s: str, k: int) -> bool:
|
||||
# Total number of unique binary codes of length k
|
||||
required = 1 << k # Same as 2^k
|
||||
|
||||
# Early termination: not enough substrings possible
|
||||
if len(s) - k + 1 < required:
|
||||
return False
|
||||
|
||||
# Collect all unique substrings of length k
|
||||
seen = set()
|
||||
for i in range(len(s) - k + 1):
|
||||
# Extract the substring at this window position
|
||||
code = s[i:i + k]
|
||||
seen.add(code)
|
||||
|
||||
# Optimisation: stop early if we've found all codes
|
||||
if len(seen) == required:
|
||||
return True
|
||||
|
||||
return len(seen) == required
|
||||
explanation: |
|
||||
**Time Complexity:** O(n * k) — We visit each of the n - k + 1 positions once, and extracting/hashing a substring of length k takes O(k) time.
|
||||
|
||||
**Space Complexity:** O(2^k * k) — The set can hold up to 2^k strings, each of length k.
|
||||
|
||||
We slide a window of size k across the string, collecting each unique substring in a hash set. If the set reaches size 2^k, we've found all possible binary codes. The early termination when `len(seen) == required` provides a small optimisation.
|
||||
|
||||
- approach_name: Bit Manipulation (Rolling Hash)
|
||||
is_optimal: true
|
||||
code: |
|
||||
def has_all_codes(s: str, k: int) -> bool:
|
||||
required = 1 << k # 2^k
|
||||
|
||||
if len(s) - k + 1 < required:
|
||||
return False
|
||||
|
||||
# Use a set of integers instead of strings
|
||||
seen = set()
|
||||
# Mask to keep only k bits (e.g., k=3 -> mask=0b111=7)
|
||||
mask = required - 1
|
||||
|
||||
# Convert first k-1 characters to a number
|
||||
current = 0
|
||||
for i in range(k - 1):
|
||||
current = (current << 1) | (ord(s[i]) - ord('0'))
|
||||
|
||||
# Slide through, updating the hash in O(1) per step
|
||||
for i in range(k - 1, len(s)):
|
||||
# Shift left and add new bit, then mask to keep k bits
|
||||
current = ((current << 1) | (ord(s[i]) - ord('0'))) & mask
|
||||
seen.add(current)
|
||||
|
||||
if len(seen) == required:
|
||||
return True
|
||||
|
||||
return len(seen) == required
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Each position is processed in O(1) time since we update the hash with bit operations.
|
||||
|
||||
**Space Complexity:** O(2^k) — The set stores up to 2^k integers.
|
||||
|
||||
Instead of storing substrings, we convert each k-length window to an integer. For example, "101" becomes 5. The rolling hash uses bit shifts: shift left by 1, add the new bit, and mask off the oldest bit. This avoids the O(k) cost of substring extraction and hashing, reducing time complexity from O(n * k) to O(n).
|
||||
|
||||
- approach_name: Brute Force (Generate and Search)
|
||||
is_optimal: false
|
||||
code: |
|
||||
def has_all_codes(s: str, k: int) -> bool:
|
||||
# Generate all 2^k binary codes
|
||||
required = 1 << k
|
||||
|
||||
for code_num in range(required):
|
||||
# Convert number to binary string of length k
|
||||
code = bin(code_num)[2:].zfill(k)
|
||||
|
||||
# Check if this code exists in s
|
||||
if code not in s:
|
||||
return False
|
||||
|
||||
return True
|
||||
explanation: |
|
||||
**Time Complexity:** O(n * 2^k) — For each of the 2^k codes, we search the string which takes O(n) time.
|
||||
|
||||
**Space Complexity:** O(k) — We only store one code string at a time.
|
||||
|
||||
This approach generates every possible binary code and checks if it's a substring of s. While intuitive, it's inefficient for large k values. With k = 20, we'd perform over a million substring searches. This solution may cause TLE on LeetCode but illustrates the straightforward approach that the optimal solutions improve upon.
|
||||
@@ -0,0 +1,151 @@
|
||||
title: Check If All 1's Are at Least Length K Places Away
|
||||
slug: check-if-all-1s-are-at-least-length-k-places-away
|
||||
difficulty: easy
|
||||
leetcode_id: 1437
|
||||
leetcode_url: https://leetcode.com/problems/check-if-all-1s-are-at-least-length-k-places-away/
|
||||
categories:
|
||||
- arrays
|
||||
patterns:
|
||||
- two-pointers
|
||||
|
||||
description: |
|
||||
Given a binary array `nums` and an integer `k`, return `true` *if all* `1`*'s are at least* `k` *places away from each other, otherwise return* `false`.
|
||||
|
||||
In other words, for every pair of `1`s in the array, there must be at least `k` elements (zeros) between them.
|
||||
|
||||
constraints: |
|
||||
- `1 <= nums.length <= 10^5`
|
||||
- `0 <= k <= nums.length`
|
||||
- `nums[i]` is `0` or `1`
|
||||
|
||||
examples:
|
||||
- input: "nums = [1,0,0,0,1,0,0,1], k = 2"
|
||||
output: "true"
|
||||
explanation: "Each of the 1s are at least 2 places away from each other. The first and second 1s have 3 zeros between them, and the second and third 1s have 2 zeros between them."
|
||||
- input: "nums = [1,0,0,1,0,1], k = 2"
|
||||
output: "false"
|
||||
explanation: "The second 1 (index 3) and third 1 (index 5) are only one position apart from each other (distance = 2 - 1 = 1 < k)."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Think of this problem as checking distances between consecutive markers on a number line.
|
||||
|
||||
Imagine the array as a row of positions, where some positions have a flag (`1`) and others are empty (`0`). Your task is to verify that every pair of *adjacent* flags has at least `k` empty positions between them.
|
||||
|
||||
The key insight is that you only need to check **consecutive** pairs of `1`s. If every consecutive pair has sufficient distance, then all pairs automatically satisfy the condition. This is because if `1` at position A and `1` at position B are far enough apart, and `1` at position B and `1` at position C are far enough apart, then A and C are definitely far enough apart (distance(A,C) = distance(A,B) + distance(B,C)).
|
||||
|
||||
As you iterate through the array, whenever you encounter a `1`, you check how far it is from the *previous* `1`. If this distance is less than or equal to `k`, the condition is violated.
|
||||
|
||||
approach: |
|
||||
We solve this using a **Single Pass with Position Tracking** approach:
|
||||
|
||||
**Step 1: Initialise tracking variable**
|
||||
|
||||
- `prev_one_index`: Set to `-k - 1` initially (or a very negative value), so that the first `1` we encounter won't falsely trigger a violation
|
||||
|
||||
|
||||
|
||||
**Step 2: Iterate through the array**
|
||||
|
||||
- For each index `i`, check if `nums[i] == 1`
|
||||
- If it's a `1`, calculate the distance from the previous `1`: `i - prev_one_index`
|
||||
- If this distance is less than or equal to `k`, return `false` immediately
|
||||
- Otherwise, update `prev_one_index = i` to track this `1` as the new reference point
|
||||
|
||||
|
||||
|
||||
**Step 3: Return the result**
|
||||
|
||||
- If we complete the loop without returning `false`, all `1`s are sufficiently spaced
|
||||
- Return `true`
|
||||
|
||||
|
||||
|
||||
The trick of initialising `prev_one_index` to `-k - 1` ensures the first `1` automatically passes the distance check (since `i - (-k-1) = i + k + 1 > k` for any non-negative `i`).
|
||||
|
||||
common_pitfalls:
|
||||
- title: Off-by-One in Distance Calculation
|
||||
description: |
|
||||
A common mistake is confusing "k places away" with "distance of k".
|
||||
|
||||
If two `1`s are at indices 2 and 5, the distance is `5 - 2 = 3`, meaning there are **2 zeros** between them (indices 3 and 4).
|
||||
|
||||
Being "k places away" means the **distance** (difference in indices) must be **greater than k**, not greater than or equal to. With `k = 2`, a distance of exactly 2 means only 1 zero between them, which violates the condition.
|
||||
|
||||
The check should be `distance <= k` returns `false`, or equivalently, we need `distance > k` to pass.
|
||||
wrong_approach: "Checking if distance >= k"
|
||||
correct_approach: "Checking if distance > k (or distance <= k returns false)"
|
||||
|
||||
- title: Forgetting to Handle the First 1
|
||||
description: |
|
||||
If you initialise `prev_one_index` to `0` or don't handle it specially, you might incorrectly flag the first `1` as violating the condition.
|
||||
|
||||
For example, with `nums = [0,0,1,...]`, if `prev_one_index = 0`, then when we reach index 2, we'd compute distance = 2, which could falsely fail for `k >= 2`.
|
||||
|
||||
Initialising to `-k - 1` (or any value ≤ `-k - 1`) ensures the first `1` always passes since its "distance from the previous" will always be greater than `k`.
|
||||
wrong_approach: "Initialising prev_one_index to 0"
|
||||
correct_approach: "Initialising prev_one_index to -k - 1 or using a flag for first occurrence"
|
||||
|
||||
- title: Checking All Pairs Instead of Consecutive
|
||||
description: |
|
||||
A brute force approach might store all indices of `1`s and check every pair combination, resulting in O(n^2) worst case if many `1`s exist.
|
||||
|
||||
This is unnecessary because checking only consecutive pairs is sufficient. If A-B and B-C satisfy the distance requirement, A-C automatically does too.
|
||||
wrong_approach: "O(n^2) checking all pairs of 1s"
|
||||
correct_approach: "O(n) checking only consecutive pairs"
|
||||
|
||||
key_takeaways:
|
||||
- "**Single-pass pattern**: Many array problems can be solved by tracking key information (like the last occurrence) as you iterate"
|
||||
- "**Initialisation trick**: Setting the initial state to a \"safe\" value (like `-k - 1`) eliminates the need for special-case handling"
|
||||
- "**Consecutive suffices**: When checking pairwise properties with transitivity, you only need to verify consecutive pairs"
|
||||
- "**Early termination**: Return `false` as soon as a violation is found — no need to continue checking"
|
||||
|
||||
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 single integer variable (`prev_one_index`) regardless of the input size."
|
||||
|
||||
solutions:
|
||||
- approach_name: Single Pass with Position Tracking
|
||||
is_optimal: true
|
||||
code: |
|
||||
def k_length_apart(nums: list[int], k: int) -> bool:
|
||||
# Initialise to a value that ensures the first 1 passes
|
||||
# Any index minus this will be > k
|
||||
prev_one_index = -k - 1
|
||||
|
||||
for i, num in enumerate(nums):
|
||||
if num == 1:
|
||||
# Check distance from previous 1
|
||||
if i - prev_one_index <= k:
|
||||
# Distance is too small, condition violated
|
||||
return False
|
||||
# Update the position of the last seen 1
|
||||
prev_one_index = i
|
||||
|
||||
# All consecutive 1s are sufficiently spaced
|
||||
return True
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Single pass through the array.
|
||||
|
||||
**Space Complexity:** O(1) — Only one variable used.
|
||||
|
||||
We iterate through the array once, tracking the index of the most recent `1`. When we encounter a new `1`, we check if the distance from the previous `1` exceeds `k`. The clever initialisation of `prev_one_index = -k - 1` handles the edge case of the first `1` elegantly.
|
||||
|
||||
- approach_name: Collect Indices Then Check
|
||||
is_optimal: false
|
||||
code: |
|
||||
def k_length_apart(nums: list[int], k: int) -> bool:
|
||||
# Collect all indices where nums[i] == 1
|
||||
one_indices = [i for i, num in enumerate(nums) if num == 1]
|
||||
|
||||
# Check consecutive pairs
|
||||
for i in range(1, len(one_indices)):
|
||||
if one_indices[i] - one_indices[i - 1] <= k:
|
||||
return False
|
||||
|
||||
return True
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Two passes: one to collect indices, one to check pairs.
|
||||
|
||||
**Space Complexity:** O(m) — Where m is the number of 1s in the array (could be O(n) in worst case).
|
||||
|
||||
This approach first collects all positions of `1`s into a list, then checks consecutive pairs. While still O(n) time, it uses extra space and requires two passes. The single-pass approach is preferred for both space efficiency and early termination capability.
|
||||
@@ -0,0 +1,150 @@
|
||||
title: Check if All Characters Have Equal Number of Occurrences
|
||||
slug: check-if-all-characters-have-equal-number-of-occurrences
|
||||
difficulty: easy
|
||||
leetcode_id: 1941
|
||||
leetcode_url: https://leetcode.com/problems/check-if-all-characters-have-equal-number-of-occurrences/
|
||||
categories:
|
||||
- strings
|
||||
- hash-tables
|
||||
patterns:
|
||||
- prefix-sum
|
||||
|
||||
description: |
|
||||
Given a string `s`, return `true` *if* `s` *is a **good** string, or* `false` *otherwise*.
|
||||
|
||||
A string `s` is **good** if **all** the characters that appear in `s` have the **same number of occurrences** (i.e., the same frequency).
|
||||
|
||||
constraints: |
|
||||
- `1 <= s.length <= 1000`
|
||||
- `s` consists of lowercase English letters.
|
||||
|
||||
examples:
|
||||
- input: 's = "abacbc"'
|
||||
output: "true"
|
||||
explanation: "The characters that appear in s are 'a', 'b', and 'c'. All characters occur 2 times in s."
|
||||
- input: 's = "aaabb"'
|
||||
output: "false"
|
||||
explanation: "The characters that appear in s are 'a' and 'b'. 'a' occurs 3 times while 'b' occurs 2 times, which is not the same number of times."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Think of this problem as counting items in different buckets, then checking if all buckets have the same number of items.
|
||||
|
||||
Imagine you have a collection of coloured marbles and you want to know if every colour appears the same number of times. You would first count how many marbles of each colour you have, then compare all these counts.
|
||||
|
||||
The key insight is that we need to do two things:
|
||||
1. **Count** how many times each character appears
|
||||
2. **Compare** all those counts to see if they're all equal
|
||||
|
||||
If every frequency is the same, the string is "good". Otherwise, it's not. This naturally leads to using a hash table to store counts, then checking if all values in the hash table are identical.
|
||||
|
||||
approach: |
|
||||
We solve this using a **Hash Map with Set Comparison** approach:
|
||||
|
||||
**Step 1: Count character frequencies**
|
||||
|
||||
- Create a hash map (dictionary) to store the frequency of each character
|
||||
- Iterate through the string and increment the count for each character
|
||||
- After this step, we have a mapping from each unique character to its count
|
||||
|
||||
|
||||
|
||||
**Step 2: Check if all frequencies are equal**
|
||||
|
||||
- Extract all the frequency values from our hash map
|
||||
- Convert these values to a set (which removes duplicates)
|
||||
- If the set has exactly one element, all frequencies were equal
|
||||
- If the set has more than one element, frequencies differ
|
||||
|
||||
|
||||
|
||||
**Step 3: Return the result**
|
||||
|
||||
- Return `true` if there's only one unique frequency
|
||||
- Return `false` otherwise
|
||||
|
||||
|
||||
|
||||
This approach works because a set automatically eliminates duplicates. If all characters have the same frequency (say, 2), then converting `[2, 2, 2]` to a set gives `{2}` with length 1.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Counting Characters Not in the String
|
||||
description: |
|
||||
A mistake is to check if *all 26 letters* have the same frequency. This would always return `false` for most strings because letters not present have frequency 0.
|
||||
|
||||
The problem asks about characters that **appear** in the string. Only count and compare characters that actually exist in the input.
|
||||
wrong_approach: "Checking all 26 letters have equal frequency"
|
||||
correct_approach: "Only compare frequencies of characters present in the string"
|
||||
|
||||
- title: Forgetting Single Character Strings
|
||||
description: |
|
||||
For a string like `"a"`, there's only one character with frequency 1. This should return `true` since the single character trivially has equal frequency with itself.
|
||||
|
||||
Edge case: A string with only one unique character is always "good", regardless of how many times it repeats.
|
||||
wrong_approach: "Not handling edge case where only one character type exists"
|
||||
correct_approach: "The set-based solution handles this naturally since a single frequency means set size is 1"
|
||||
|
||||
- title: Using Sorting Instead of Sets
|
||||
description: |
|
||||
Some might sort the frequencies and then compare adjacent elements. While this works, it's less efficient — sorting takes O(k log k) where k is the number of unique characters.
|
||||
|
||||
Using a set is simpler and can be O(k) for insertion and comparison, making the code both cleaner and faster.
|
||||
wrong_approach: "Sorting frequencies and comparing"
|
||||
correct_approach: "Using a set to check for unique frequency count"
|
||||
|
||||
key_takeaways:
|
||||
- "**Hash map for counting**: Frequency counting is a fundamental hash table pattern that appears in many string problems"
|
||||
- "**Set for uniqueness check**: Converting to a set and checking its size is an elegant way to verify if all values are equal"
|
||||
- "**Problem decomposition**: Break down into two clear steps — count first, then compare"
|
||||
- "**Related problems**: This technique extends to problems like finding if an array can be rearranged, checking for anagrams, and other frequency-based challenges"
|
||||
|
||||
time_complexity: "O(n). We iterate through the string once to count frequencies, then iterate through the frequency map once to build the set."
|
||||
space_complexity: "O(k) where k is the number of unique characters. In the worst case with lowercase English letters, k ≤ 26, so this is effectively O(1)."
|
||||
|
||||
solutions:
|
||||
- approach_name: Hash Map with Set
|
||||
is_optimal: true
|
||||
code: |
|
||||
from collections import Counter
|
||||
|
||||
def are_occurrences_equal(s: str) -> bool:
|
||||
# Count frequency of each character
|
||||
freq = Counter(s)
|
||||
|
||||
# Get all frequency values and convert to set
|
||||
# If all frequencies are equal, set will have size 1
|
||||
unique_frequencies = set(freq.values())
|
||||
|
||||
return len(unique_frequencies) == 1
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — We traverse the string once to build the counter.
|
||||
|
||||
**Space Complexity:** O(k) — We store at most k unique characters, where k ≤ 26 for lowercase letters.
|
||||
|
||||
Using Python's `Counter` makes the counting step concise. The set conversion elegantly handles the comparison — if the set has exactly one element, all frequencies were identical.
|
||||
|
||||
- approach_name: Manual Counting with First Comparison
|
||||
is_optimal: false
|
||||
code: |
|
||||
def are_occurrences_equal(s: str) -> bool:
|
||||
# Build frequency map manually
|
||||
freq = {}
|
||||
for char in s:
|
||||
freq[char] = freq.get(char, 0) + 1
|
||||
|
||||
# Get frequencies as a list
|
||||
counts = list(freq.values())
|
||||
|
||||
# Compare all frequencies to the first one
|
||||
first_count = counts[0]
|
||||
for count in counts:
|
||||
if count != first_count:
|
||||
return False
|
||||
|
||||
return True
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Single pass to count, then iterate through unique characters.
|
||||
|
||||
**Space Complexity:** O(k) — Same as optimal, storing up to k character frequencies.
|
||||
|
||||
This approach is more explicit but less Pythonic. We manually build the frequency map, then compare each frequency to the first. It achieves the same result but with more code.
|
||||
@@ -0,0 +1,172 @@
|
||||
title: Check if All the Integers in a Range Are Covered
|
||||
slug: check-if-all-integers-in-range-are-covered
|
||||
difficulty: easy
|
||||
leetcode_id: 1893
|
||||
leetcode_url: https://leetcode.com/problems/check-if-all-the-integers-in-a-range-are-covered/
|
||||
categories:
|
||||
- arrays
|
||||
- hash-tables
|
||||
patterns:
|
||||
- prefix-sum
|
||||
|
||||
description: |
|
||||
You are given a 2D integer array `ranges` and two integers `left` and `right`. Each `ranges[i] = [start_i, end_i]` represents an **inclusive** interval between `start_i` and `end_i`.
|
||||
|
||||
Return `true` *if each integer in the inclusive range* `[left, right]` *is covered by **at least one** interval in* `ranges`. Return `false` *otherwise*.
|
||||
|
||||
An integer `x` is covered by an interval `ranges[i] = [start_i, end_i]` if `start_i <= x <= end_i`.
|
||||
|
||||
constraints: |
|
||||
- `1 <= ranges.length <= 50`
|
||||
- `1 <= start_i <= end_i <= 50`
|
||||
- `1 <= left <= right <= 50`
|
||||
|
||||
examples:
|
||||
- input: "ranges = [[1,2],[3,4],[5,6]], left = 2, right = 5"
|
||||
output: "true"
|
||||
explanation: "Every integer between 2 and 5 is covered: 2 is covered by the first range, 3 and 4 are covered by the second range, and 5 is covered by the third range."
|
||||
- input: "ranges = [[1,10],[10,20]], left = 21, right = 21"
|
||||
output: "false"
|
||||
explanation: "21 is not covered by any range."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Imagine you have a number line with some segments painted on it. Each interval `[start, end]` paints all integers from `start` to `end`. Your task is to check whether every integer from `left` to `right` has been painted at least once.
|
||||
|
||||
Think of it like checking if a road is fully paved: you have several paving crews, each covering a section of road. You need to verify that the stretch from mile marker `left` to mile marker `right` has no gaps.
|
||||
|
||||
The key insight is that with small constraints (all values ≤ 50), we can afford to explicitly mark which integers are covered. We don't need sophisticated interval merging — we can simply "paint" each covered integer in a set or array and then check if our target range is fully painted.
|
||||
|
||||
approach: |
|
||||
We solve this using a **Set-Based Coverage Check**:
|
||||
|
||||
**Step 1: Create a set to track covered integers**
|
||||
|
||||
- `covered`: An empty set that will store all integers covered by any interval
|
||||
|
||||
|
||||
|
||||
**Step 2: Mark all covered integers**
|
||||
|
||||
- For each interval `[start, end]` in `ranges`:
|
||||
- Add every integer from `start` to `end` (inclusive) to the `covered` set
|
||||
|
||||
|
||||
|
||||
**Step 3: Check if the target range is fully covered**
|
||||
|
||||
- For each integer `i` from `left` to `right`:
|
||||
- If `i` is not in `covered`, return `false` immediately
|
||||
- If we complete the loop without finding a gap, return `true`
|
||||
|
||||
|
||||
|
||||
This approach leverages the small constraint (values ≤ 50) to use O(n) space where n is the range of possible values, which is acceptable and leads to a clean, readable solution.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Over-Engineering with Interval Merging
|
||||
description: |
|
||||
A tempting approach is to sort and merge intervals, then perform a binary search or scan. While this works, it's unnecessary complexity for this problem.
|
||||
|
||||
With constraints limiting all values to 50 or less, the simpler set-based approach is both efficient and easier to implement correctly. Save interval merging for problems where the value range is large (e.g., `10^9`).
|
||||
wrong_approach: "Complex interval sorting and merging"
|
||||
correct_approach: "Simple set-based coverage tracking"
|
||||
|
||||
- title: Using Only the Query Range
|
||||
description: |
|
||||
Some might try to optimise by only considering integers in `[left, right]` when iterating through intervals. While this works, it's an unnecessary micro-optimisation.
|
||||
|
||||
Given the small constraints, marking all covered integers is fast and leads to simpler code. The overall complexity remains O(n × m) where n is the number of intervals and m is the average interval size.
|
||||
|
||||
- title: Off-by-One Errors
|
||||
description: |
|
||||
Remember that intervals are **inclusive** on both ends. When marking `[start, end]`, you must include both `start` and `end`.
|
||||
|
||||
Similarly, when checking `[left, right]`, ensure you check the entire inclusive range. Use `range(left, right + 1)` in Python to include `right`.
|
||||
wrong_approach: "range(start, end) missing the endpoint"
|
||||
correct_approach: "range(start, end + 1) to include both boundaries"
|
||||
|
||||
key_takeaways:
|
||||
- "**Leverage constraints**: When value ranges are small (≤ 50), direct marking with sets or arrays is simpler than interval algorithms"
|
||||
- "**Sets for membership**: Python sets provide O(1) lookup, making coverage checks efficient"
|
||||
- "**Foundation for harder problems**: This concept extends to problems like merge intervals, meeting rooms, and range coverage with larger constraints"
|
||||
- "**Simplicity wins**: Don't over-engineer — choose the simplest approach that meets the complexity requirements"
|
||||
|
||||
time_complexity: "O(n × m + k). We iterate through each of the `n` intervals, marking up to `m` integers per interval (where `m ≤ 50`). Then we check `k = right - left + 1` integers for coverage."
|
||||
space_complexity: "O(n × m). In the worst case, we store all covered integers in the set, which could be up to 50 unique values given the constraints."
|
||||
|
||||
solutions:
|
||||
- approach_name: Set-Based Coverage
|
||||
is_optimal: true
|
||||
code: |
|
||||
def is_covered(ranges: list[list[int]], left: int, right: int) -> bool:
|
||||
# Track all integers covered by any interval
|
||||
covered = set()
|
||||
|
||||
# Mark each integer in every interval as covered
|
||||
for start, end in ranges:
|
||||
for i in range(start, end + 1):
|
||||
covered.add(i)
|
||||
|
||||
# Check if every integer in [left, right] is covered
|
||||
for i in range(left, right + 1):
|
||||
if i not in covered:
|
||||
return False
|
||||
|
||||
return True
|
||||
explanation: |
|
||||
**Time Complexity:** O(n × m + k) — We process all intervals and then check the query range.
|
||||
|
||||
**Space Complexity:** O(n × m) — We store all covered integers in a set.
|
||||
|
||||
This approach directly marks all covered integers and then verifies the target range. Clean, readable, and efficient given the small constraints.
|
||||
|
||||
- approach_name: Direct Range Check
|
||||
is_optimal: false
|
||||
code: |
|
||||
def is_covered(ranges: list[list[int]], left: int, right: int) -> bool:
|
||||
# Check each integer in the target range
|
||||
for num in range(left, right + 1):
|
||||
# See if any interval covers this number
|
||||
is_num_covered = False
|
||||
for start, end in ranges:
|
||||
if start <= num <= end:
|
||||
is_num_covered = True
|
||||
break
|
||||
|
||||
# If no interval covers this number, return False
|
||||
if not is_num_covered:
|
||||
return False
|
||||
|
||||
return True
|
||||
explanation: |
|
||||
**Time Complexity:** O(k × n) — For each of the `k` integers in `[left, right]`, we check up to `n` intervals.
|
||||
|
||||
**Space Complexity:** O(1) — No additional data structures used.
|
||||
|
||||
This approach checks each number individually against all intervals. It uses constant space but may do redundant work checking the same intervals multiple times. Still efficient given the small constraints.
|
||||
|
||||
- approach_name: Boolean Array (Difference Array Concept)
|
||||
is_optimal: false
|
||||
code: |
|
||||
def is_covered(ranges: list[list[int]], left: int, right: int) -> bool:
|
||||
# Boolean array to mark covered positions (index 0-51)
|
||||
covered = [False] * 52
|
||||
|
||||
# Mark all positions covered by each interval
|
||||
for start, end in ranges:
|
||||
for i in range(start, end + 1):
|
||||
covered[i] = True
|
||||
|
||||
# Check if all positions in [left, right] are covered
|
||||
for i in range(left, right + 1):
|
||||
if not covered[i]:
|
||||
return False
|
||||
|
||||
return True
|
||||
explanation: |
|
||||
**Time Complexity:** O(n × m + k) — Same as the set-based approach.
|
||||
|
||||
**Space Complexity:** O(52) = O(1) — Fixed-size array based on constraint that values ≤ 50.
|
||||
|
||||
This variant uses a boolean array instead of a set. It's slightly more memory-efficient since we know the maximum value is 50. The array approach hints at the "difference array" technique used for larger constraint problems.
|
||||
@@ -0,0 +1,266 @@
|
||||
title: Check if an Original String Exists Given Two Encoded Strings
|
||||
slug: check-if-an-original-string-exists-given-two-encoded-strings
|
||||
difficulty: hard
|
||||
leetcode_id: 2060
|
||||
leetcode_url: https://leetcode.com/problems/check-if-an-original-string-exists-given-two-encoded-strings/
|
||||
categories:
|
||||
- strings
|
||||
- dynamic-programming
|
||||
patterns:
|
||||
- dynamic-programming
|
||||
|
||||
description: |
|
||||
An original string, consisting of lowercase English letters, can be encoded by the following steps:
|
||||
|
||||
- Arbitrarily **split** it into a **sequence** of some number of **non-empty** substrings.
|
||||
- Arbitrarily choose some elements (possibly none) of the sequence, and **replace** each with **its length** (as a numeric string).
|
||||
- **Concatenate** the sequence as the encoded string.
|
||||
|
||||
For example, **one way** to encode an original string `"abcdefghijklmnop"` might be:
|
||||
|
||||
- Split it as a sequence: `["ab", "cdefghijklmn", "o", "p"]`.
|
||||
- Choose the second and third elements to be replaced by their lengths, respectively. The sequence becomes `["ab", "12", "1", "p"]`.
|
||||
- Concatenate the elements of the sequence to get the encoded string: `"ab121p"`.
|
||||
|
||||
Given two encoded strings `s1` and `s2`, consisting of lowercase English letters and digits `1-9` (inclusive), return `true` *if there exists an original string that could be encoded as **both*** `s1` *and* `s2`. Otherwise, return `false`.
|
||||
|
||||
**Note:** The test cases are generated such that the number of consecutive digits in `s1` and `s2` does not exceed `3`.
|
||||
|
||||
constraints: |
|
||||
- `1 <= s1.length, s2.length <= 40`
|
||||
- `s1` and `s2` consist of digits `1-9` (inclusive), and lowercase English letters only.
|
||||
- The number of consecutive digits in `s1` and `s2` does not exceed `3`.
|
||||
|
||||
examples:
|
||||
- input: 's1 = "internationalization", s2 = "i18n"'
|
||||
output: "true"
|
||||
explanation: "It is possible that 'internationalization' was the original string. s1 keeps the full string unchanged, while s2 splits it as ['i', 'nternationalizatio', 'n'] and replaces the middle part with its length '18'."
|
||||
- input: 's1 = "l123e", s2 = "44"'
|
||||
output: "true"
|
||||
explanation: "It is possible that 'leetcode' was the original string. s1 encodes it as ['l', '1', '2', '3', 'e'] and s2 encodes it as ['4', '4'] (two groups of 4 characters each)."
|
||||
- input: 's1 = "a5b", s2 = "c5b"'
|
||||
output: "false"
|
||||
explanation: "The original string encoded as s1 must start with 'a', but the original string encoded as s2 must start with 'c'. These are incompatible."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Imagine you're trying to synchronise two tape recorders playing the same song, but each has been compressed in different ways. Some parts are played normally (letters), while other parts are fast-forwarded (numbers representing skipped characters).
|
||||
|
||||
The key insight is that we need to track a **difference** in position between the two strings as we process them. When one string has a number, it's essentially saying "skip ahead by this many characters in the original." When both strings have letters, they must match exactly.
|
||||
|
||||
Think of it like this: if `s1` says "skip 5 characters" and `s2` says "the next character is 'a'", then one of those 5 skipped characters in `s1` could be the 'a' that `s2` is showing. We track this positional difference and explore all possibilities.
|
||||
|
||||
The challenge is that numbers can combine in many ways (e.g., "12" could mean 12 characters, or "1" followed by "2" meaning 1+2=3 characters). We use dynamic programming with memoisation to avoid recomputing the same states.
|
||||
|
||||
approach: |
|
||||
We solve this using **Dynamic Programming with Memoisation**:
|
||||
|
||||
**Step 1: Define the state**
|
||||
|
||||
- `i`: Current position in `s1`
|
||||
- `j`: Current position in `s2`
|
||||
- `diff`: The difference in "pending" characters between the two strings
|
||||
- Positive `diff`: `s1` has `diff` more characters to consume before matching position with `s2`
|
||||
- Negative `diff`: `s2` has `|diff|` more characters to consume
|
||||
- Zero `diff`: Both strings are at the same position in the original string
|
||||
|
||||
|
||||
|
||||
**Step 2: Handle the base case**
|
||||
|
||||
- If both `i == len(s1)` and `j == len(s2)` and `diff == 0`, we've successfully matched both strings to the same original string
|
||||
- Return `True` in this case
|
||||
|
||||
|
||||
|
||||
**Step 3: Process each state recursively**
|
||||
|
||||
- **If `diff > 0`**: `s1` is ahead, so we need `s2` to catch up
|
||||
- If `s2[j]` is a digit, parse all consecutive digits and add to `s2`'s count (reduce `diff`)
|
||||
- If `s2[j]` is a letter, it consumes one character from the difference (`diff -= 1`)
|
||||
|
||||
- **If `diff < 0`**: `s2` is ahead, so we need `s1` to catch up
|
||||
- If `s1[i]` is a digit, parse all consecutive digits and add to `s1`'s count (increase `diff`)
|
||||
- If `s1[i]` is a letter, it consumes one character from the difference (`diff += 1`)
|
||||
|
||||
- **If `diff == 0`**: Both are synchronised
|
||||
- If both have letters, they must match exactly
|
||||
- If one has a digit, parse it and update the difference accordingly
|
||||
|
||||
|
||||
|
||||
**Step 4: Use memoisation**
|
||||
|
||||
- Cache results for `(i, j, diff)` states to avoid redundant computation
|
||||
- The `diff` range is bounded by the maximum possible encoded length (up to 999 per number group)
|
||||
|
||||
|
||||
|
||||
This approach systematically explores all valid ways the two encoded strings could represent the same original string.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Mishandling Multi-Digit Numbers
|
||||
description: |
|
||||
The encoded strings can have up to 3 consecutive digits (e.g., "123" meaning 123 characters). A common mistake is to process digits one at a time instead of considering all possible interpretations.
|
||||
|
||||
For example, "12" could be:
|
||||
- The number 12 (twelve characters)
|
||||
- The number 1 followed by the number 2 (1 + 2 = 3 characters)
|
||||
|
||||
You must explore **all valid splits** of consecutive digits, not just treat them as a single number.
|
||||
wrong_approach: "Always parsing consecutive digits as one number"
|
||||
correct_approach: "Try all possible ways to split digit sequences"
|
||||
|
||||
- title: Forgetting Negative Diff Values
|
||||
description: |
|
||||
The difference `diff` can be negative when `s2` is "ahead" of `s1`. If you only track positive differences, you'll miss valid matches.
|
||||
|
||||
For example, if `s1 = "a2b"` and `s2 = "3b"`, after processing 'a' from `s1` and '3' from `s2`, the diff becomes -2 (s2 has 2 more pending characters). The 'a' from `s1` consumes one of those, leaving diff = -1.
|
||||
wrong_approach: "Only tracking when s1 is ahead"
|
||||
correct_approach: "Track both positive and negative differences"
|
||||
|
||||
- title: Not Handling Empty Remaining Strings
|
||||
description: |
|
||||
When one string is exhausted but the other still has content, you need to handle this carefully.
|
||||
|
||||
If `s1` is exhausted but `diff > 0` and `s2` has remaining digits, those digits might exactly cancel out the difference. Similarly for the reverse case.
|
||||
|
||||
The recursion must continue until both strings are exhausted AND `diff == 0`.
|
||||
wrong_approach: "Returning False immediately when one string is exhausted"
|
||||
correct_approach: "Continue processing until both strings are exhausted with diff = 0"
|
||||
|
||||
key_takeaways:
|
||||
- "**State-based DP**: When comparing two sequences with flexible interpretations, define a state that captures the essential difference between positions"
|
||||
- "**Difference tracking**: Instead of tracking absolute positions in a hypothetical original string, track the relative difference between the two encoded strings"
|
||||
- "**Exploring all splits**: When parsing numbers, consider all valid ways to split consecutive digits, not just the maximum length interpretation"
|
||||
- "**Memoisation is essential**: The state space is bounded (positions in both strings plus a bounded difference), making memoisation highly effective"
|
||||
|
||||
time_complexity: "O(n * m * D) where `n` and `m` are the lengths of `s1` and `s2`, and `D` is the range of possible difference values (bounded by 999 since numbers have at most 3 digits)."
|
||||
space_complexity: "O(n * m * D) for the memoisation cache storing results for each unique `(i, j, diff)` state."
|
||||
|
||||
solutions:
|
||||
- approach_name: Dynamic Programming with Memoisation
|
||||
is_optimal: true
|
||||
code: |
|
||||
def possiblyEquals(s1: str, s2: str) -> bool:
|
||||
from functools import lru_cache
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def dp(i: int, j: int, diff: int) -> bool:
|
||||
# Base case: both strings exhausted and difference is zero
|
||||
if i == len(s1) and j == len(s2):
|
||||
return diff == 0
|
||||
|
||||
# If diff > 0, s1 is ahead - s2 needs to catch up
|
||||
if diff > 0:
|
||||
if j < len(s2):
|
||||
if s2[j].isdigit():
|
||||
# Try all possible ways to parse consecutive digits
|
||||
num = 0
|
||||
for k in range(j, len(s2)):
|
||||
if not s2[k].isdigit():
|
||||
break
|
||||
num = num * 10 + int(s2[k])
|
||||
# s2 advances by 'num' characters, reducing diff
|
||||
if dp(i, k + 1, diff - num):
|
||||
return True
|
||||
else:
|
||||
# s2 has a letter - it consumes one from the difference
|
||||
if dp(i, j + 1, diff - 1):
|
||||
return True
|
||||
return False
|
||||
|
||||
# If diff < 0, s2 is ahead - s1 needs to catch up
|
||||
if diff < 0:
|
||||
if i < len(s1):
|
||||
if s1[i].isdigit():
|
||||
# Try all possible ways to parse consecutive digits
|
||||
num = 0
|
||||
for k in range(i, len(s1)):
|
||||
if not s1[k].isdigit():
|
||||
break
|
||||
num = num * 10 + int(s1[k])
|
||||
# s1 advances by 'num' characters, increasing diff
|
||||
if dp(k + 1, j, diff + num):
|
||||
return True
|
||||
else:
|
||||
# s1 has a letter - it consumes one from the difference
|
||||
if dp(i + 1, j, diff + 1):
|
||||
return True
|
||||
return False
|
||||
|
||||
# diff == 0: both are synchronised
|
||||
# Both exhausted - already handled in base case
|
||||
if i == len(s1):
|
||||
# s1 exhausted, s2 must have digits to create difference
|
||||
if s2[j].isdigit():
|
||||
num = 0
|
||||
for k in range(j, len(s2)):
|
||||
if not s2[k].isdigit():
|
||||
break
|
||||
num = num * 10 + int(s2[k])
|
||||
if dp(i, k + 1, -num):
|
||||
return True
|
||||
return False
|
||||
|
||||
if j == len(s2):
|
||||
# s2 exhausted, s1 must have digits to create difference
|
||||
if s1[i].isdigit():
|
||||
num = 0
|
||||
for k in range(i, len(s1)):
|
||||
if not s1[k].isdigit():
|
||||
break
|
||||
num = num * 10 + int(s1[k])
|
||||
if dp(k + 1, j, num):
|
||||
return True
|
||||
return False
|
||||
|
||||
# Both have characters at current positions
|
||||
if s1[i].isdigit() and s2[j].isdigit():
|
||||
# Both are digits - try all combinations
|
||||
for k1 in range(i, len(s1)):
|
||||
if not s1[k1].isdigit():
|
||||
break
|
||||
num1 = int(s1[i:k1+1])
|
||||
for k2 in range(j, len(s2)):
|
||||
if not s2[k2].isdigit():
|
||||
break
|
||||
num2 = int(s2[j:k2+1])
|
||||
if dp(k1 + 1, k2 + 1, num1 - num2):
|
||||
return True
|
||||
return False
|
||||
|
||||
if s1[i].isdigit():
|
||||
# s1 has digit, s2 has letter
|
||||
num = 0
|
||||
for k in range(i, len(s1)):
|
||||
if not s1[k].isdigit():
|
||||
break
|
||||
num = num * 10 + int(s1[k])
|
||||
if dp(k + 1, j, num):
|
||||
return True
|
||||
return False
|
||||
|
||||
if s2[j].isdigit():
|
||||
# s2 has digit, s1 has letter
|
||||
num = 0
|
||||
for k in range(j, len(s2)):
|
||||
if not s2[k].isdigit():
|
||||
break
|
||||
num = num * 10 + int(s2[k])
|
||||
if dp(i, k + 1, -num):
|
||||
return True
|
||||
return False
|
||||
|
||||
# Both are letters - they must match
|
||||
if s1[i] == s2[j]:
|
||||
return dp(i + 1, j + 1, 0)
|
||||
return False
|
||||
|
||||
return dp(0, 0, 0)
|
||||
explanation: |
|
||||
**Time Complexity:** O(n * m * D) where n and m are string lengths and D is the difference range (up to 999).
|
||||
|
||||
**Space Complexity:** O(n * m * D) for memoisation cache.
|
||||
|
||||
We use recursion with memoisation to explore all valid ways the two encoded strings could represent the same original string. The key insight is tracking the "difference" between positions - when one string encodes characters as a number, it creates a positional difference that the other string must eventually match.
|
||||
156
backend/data/questions/check-if-array-is-sorted-and-rotated.yaml
Normal file
156
backend/data/questions/check-if-array-is-sorted-and-rotated.yaml
Normal file
@@ -0,0 +1,156 @@
|
||||
title: Check if Array Is Sorted and Rotated
|
||||
slug: check-if-array-is-sorted-and-rotated
|
||||
difficulty: easy
|
||||
leetcode_id: 1752
|
||||
leetcode_url: https://leetcode.com/problems/check-if-array-is-sorted-and-rotated/
|
||||
categories:
|
||||
- arrays
|
||||
patterns:
|
||||
- two-pointers
|
||||
|
||||
description: |
|
||||
Given an array `nums`, return `true` *if the array was originally sorted in non-decreasing order, then rotated **some** number of positions (including zero)*. Otherwise, return `false`.
|
||||
|
||||
There may be **duplicates** in the original array.
|
||||
|
||||
**Note:** An array `A` rotated by `x` positions results in an array `B` of the same length such that `B[i] == A[(i+x) % A.length]` for every valid index `i`.
|
||||
|
||||
constraints: |
|
||||
- `1 <= nums.length <= 100`
|
||||
- `1 <= nums[i] <= 100`
|
||||
|
||||
examples:
|
||||
- input: "nums = [3,4,5,1,2]"
|
||||
output: "true"
|
||||
explanation: "[1,2,3,4,5] is the original sorted array. You can rotate the array by x = 2 positions to begin on the element of value 3: [3,4,5,1,2]."
|
||||
- input: "nums = [2,1,3,4]"
|
||||
output: "false"
|
||||
explanation: "There is no sorted array once rotated that can make nums."
|
||||
- input: "nums = [1,2,3]"
|
||||
output: "true"
|
||||
explanation: "[1,2,3] is the original sorted array. You can rotate the array by x = 0 positions (i.e. no rotation) to make nums."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Think of rotation like taking a deck of cards that's in order and cutting it somewhere in the middle — you move the top portion to the bottom. The cards are still in order within each portion, but there's exactly **one place** where the sequence "breaks" (where the bottom of the cut meets the top).
|
||||
|
||||
In a sorted-then-rotated array, you'll see the numbers increasing (or staying the same for duplicates), then suddenly **drop** to a smaller value, then continue increasing again. For example, `[3,4,5,1,2]` goes up from 3→4→5, then drops to 1, then continues up from 1→2.
|
||||
|
||||
The key insight is: **a valid rotated sorted array can have at most one "breakpoint"** — one place where `nums[i] > nums[i+1]`. If you find two or more such breakpoints, the array couldn't have come from rotating a sorted array.
|
||||
|
||||
There's one subtle catch: we also need to check the **wrap-around** from the last element back to the first. If the array was rotated (not just sorted), the last element should be ≤ the first element to complete the "circle" back to sorted order.
|
||||
|
||||
approach: |
|
||||
We solve this by **counting inversions** (places where the order breaks):
|
||||
|
||||
**Step 1: Initialise a counter**
|
||||
|
||||
- `inversions`: Set to `0` to count how many times the sequence breaks
|
||||
|
||||
|
||||
|
||||
**Step 2: Scan for breakpoints in the array**
|
||||
|
||||
- Iterate through indices `0` to `n-1`
|
||||
- For each index `i`, compare `nums[i]` with `nums[(i+1) % n]`
|
||||
- Using modulo `% n` handles the wrap-around comparison (last element vs first)
|
||||
- If `nums[i] > nums[(i+1) % n]`, increment `inversions`
|
||||
|
||||
|
||||
|
||||
**Step 3: Return the result**
|
||||
|
||||
- If `inversions <= 1`, return `true` — the array is a valid rotated sorted array
|
||||
- If `inversions > 1`, return `false` — too many breakpoints to be a rotation
|
||||
|
||||
|
||||
|
||||
This works because a sorted array has zero inversions, and rotating it introduces exactly one inversion at the rotation point. The wrap-around check using modulo ensures we verify the circular property.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Forgetting the Wrap-Around Check
|
||||
description: |
|
||||
A common mistake is only checking adjacent pairs without considering the wrap-around from the last element to the first.
|
||||
|
||||
For example, `[2,3,1]` has one inversion (3→1), but we also need to verify that `1 <= 2` (last to first) for it to be a valid rotation. Using `(i+1) % n` automatically handles this by including the last-to-first comparison in our loop.
|
||||
wrong_approach: "Only check nums[i] > nums[i+1] for i < n-1"
|
||||
correct_approach: "Use modulo to include the wrap-around: nums[i] > nums[(i+1) % n]"
|
||||
|
||||
- title: Off-by-One in Loop Bounds
|
||||
description: |
|
||||
If you iterate `i` from `0` to `n-2` (exclusive of the last element) and then separately check the wrap-around, you might miss edge cases or double-count.
|
||||
|
||||
The cleaner approach is to iterate `i` from `0` to `n-1` and always use modulo arithmetic. This uniformly handles all comparisons including the wrap-around.
|
||||
wrong_approach: "Separate logic for internal pairs and wrap-around"
|
||||
correct_approach: "Single loop with modulo for uniform handling"
|
||||
|
||||
- title: Confusing "Rotated" with Unsorted
|
||||
description: |
|
||||
The problem specifically asks about arrays that were **sorted then rotated**. An array like `[2,1,3,4]` has one inversion (2→1), but checking the wrap-around (4→2) reveals a second inversion. Two inversions means it's not a valid rotated sorted array.
|
||||
|
||||
Always count all inversions including the circular wrap-around before deciding.
|
||||
|
||||
key_takeaways:
|
||||
- "**Rotation creates exactly one breakpoint**: A sorted array rotated `k` positions has exactly one place where `nums[i] > nums[i+1]`"
|
||||
- "**Modulo for circular arrays**: Using `(i+1) % n` is a clean way to handle wrap-around comparisons in circular/rotated array problems"
|
||||
- "**Count inversions pattern**: Counting how many times a property is violated helps validate structural constraints"
|
||||
- "**Foundation for binary search**: Understanding rotated sorted arrays is key to problems like 'Search in Rotated Sorted Array'"
|
||||
|
||||
time_complexity: "O(n). We traverse the array once, checking each adjacent pair including the wrap-around."
|
||||
space_complexity: "O(1). We only use a single counter variable regardless of input size."
|
||||
|
||||
solutions:
|
||||
- approach_name: Count Inversions
|
||||
is_optimal: true
|
||||
code: |
|
||||
def check(nums: list[int]) -> bool:
|
||||
n = len(nums)
|
||||
inversions = 0
|
||||
|
||||
# Check all adjacent pairs including wrap-around (last to first)
|
||||
for i in range(n):
|
||||
# Use modulo to wrap index back to 0 after last element
|
||||
if nums[i] > nums[(i + 1) % n]:
|
||||
inversions += 1
|
||||
|
||||
# Early exit: more than one inversion means not valid
|
||||
if inversions > 1:
|
||||
return False
|
||||
|
||||
# Valid if at most one inversion (rotation point)
|
||||
return True
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Single pass through the array with early exit optimisation.
|
||||
|
||||
**Space Complexity:** O(1) — Only one integer counter used.
|
||||
|
||||
We iterate through all elements, comparing each to its next neighbor (with wrap-around handled by modulo). A valid rotated sorted array has at most one "drop" point. The early exit when we find a second inversion provides a minor optimisation.
|
||||
|
||||
- approach_name: Find Pivot and Verify
|
||||
is_optimal: false
|
||||
code: |
|
||||
def check(nums: list[int]) -> bool:
|
||||
n = len(nums)
|
||||
pivot = -1
|
||||
|
||||
# Find the rotation pivot (where sequence breaks)
|
||||
for i in range(n - 1):
|
||||
if nums[i] > nums[i + 1]:
|
||||
# Found a breakpoint
|
||||
if pivot != -1:
|
||||
# Second breakpoint found - not valid
|
||||
return False
|
||||
pivot = i
|
||||
|
||||
# If no pivot found, array is fully sorted (rotation by 0)
|
||||
if pivot == -1:
|
||||
return True
|
||||
|
||||
# Verify wrap-around: last element should be <= first
|
||||
return nums[n - 1] <= nums[0]
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Single pass to find pivot, then O(1) wrap-around check.
|
||||
|
||||
**Space Complexity:** O(1) — Only storing the pivot index.
|
||||
|
||||
This approach explicitly finds the pivot point (rotation boundary) and then verifies the wrap-around condition separately. While correct, it's slightly more verbose than the modulo approach. The logic is: find at most one breakpoint in the array body, then confirm the circular property holds.
|
||||
@@ -0,0 +1,229 @@
|
||||
title: Check If Array Pairs Are Divisible by k
|
||||
slug: check-if-array-pairs-are-divisible-by-k
|
||||
difficulty: medium
|
||||
leetcode_id: 1497
|
||||
leetcode_url: https://leetcode.com/problems/check-if-array-pairs-are-divisible-by-k/
|
||||
categories:
|
||||
- arrays
|
||||
- hash-tables
|
||||
- math
|
||||
patterns:
|
||||
- prefix-sum
|
||||
|
||||
description: |
|
||||
Given an array of integers `arr` of even length `n` and an integer `k`.
|
||||
|
||||
We want to divide the array into exactly `n / 2` pairs such that the sum of each pair is divisible by `k`.
|
||||
|
||||
Return `true` *if you can find a way to do that or* `false` *otherwise*.
|
||||
|
||||
constraints: |
|
||||
- `arr.length == n`
|
||||
- `1 <= n <= 10^5`
|
||||
- `n` is even
|
||||
- `-10^9 <= arr[i] <= 10^9`
|
||||
- `1 <= k <= 10^5`
|
||||
|
||||
examples:
|
||||
- input: "arr = [1,2,3,4,5,10,6,7,8,9], k = 5"
|
||||
output: "true"
|
||||
explanation: "Pairs are (1,9), (2,8), (3,7), (4,6) and (5,10). Each pair sums to a multiple of 5."
|
||||
- input: "arr = [1,2,3,4,5,6], k = 7"
|
||||
output: "true"
|
||||
explanation: "Pairs are (1,6), (2,5) and (3,4). Each pair sums to 7, which is divisible by 7."
|
||||
- input: "arr = [1,2,3,4,5,6], k = 10"
|
||||
output: "false"
|
||||
explanation: "No way to divide arr into 3 pairs where each sum is divisible by 10."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Think about what it means for two numbers to sum to a multiple of `k`. If `a + b` is divisible by `k`, then `(a % k) + (b % k)` must equal either `0` or `k`.
|
||||
|
||||
Imagine you have buckets numbered `0` to `k-1`, where each bucket holds numbers with that remainder when divided by `k`. For two numbers to pair together, their remainders must "complete" each other to reach `k` (or both be zero).
|
||||
|
||||
For example, with `k = 5`:
|
||||
- A number with remainder `1` must pair with a number with remainder `4` (since `1 + 4 = 5`)
|
||||
- A number with remainder `2` must pair with a number with remainder `3`
|
||||
- A number with remainder `0` must pair with another number with remainder `0`
|
||||
|
||||
This insight transforms the problem: instead of trying to find actual pairs, we just need to **count remainders** and check if complementary buckets have equal counts.
|
||||
|
||||
approach: |
|
||||
We solve this using a **Remainder Counting Approach**:
|
||||
|
||||
**Step 1: Build a remainder frequency map**
|
||||
|
||||
- Create a hash map (or array of size `k`) to count how many numbers have each remainder
|
||||
- For each number in `arr`, compute `remainder = num % k`
|
||||
- **Handle negative numbers**: In Python, `(-3) % 5 = 2`, but in some languages it returns `-3`. To ensure a positive remainder, use `((num % k) + k) % k`
|
||||
- Increment the count for that remainder
|
||||
|
||||
|
||||
|
||||
**Step 2: Check remainder 0 separately**
|
||||
|
||||
- Numbers with remainder `0` can only pair with other numbers that have remainder `0`
|
||||
- Their count must be **even** (so they can all pair up)
|
||||
- If `count[0]` is odd, return `false`
|
||||
|
||||
|
||||
|
||||
**Step 3: Check complementary pairs**
|
||||
|
||||
- For each remainder `r` from `1` to `k/2`, check that `count[r] == count[k - r]`
|
||||
- These are complementary remainders that must pair together
|
||||
- If any pair has unequal counts, return `false`
|
||||
|
||||
|
||||
|
||||
**Step 4: Handle the middle remainder (when k is even)**
|
||||
|
||||
- If `k` is even, remainder `k/2` pairs with itself (since `k/2 + k/2 = k`)
|
||||
- Similar to remainder `0`, the count of `k/2` must be even
|
||||
- This is automatically handled in step 3 when `r == k - r`
|
||||
|
||||
|
||||
|
||||
**Step 5: Return true**
|
||||
|
||||
- If all checks pass, we can form valid pairs
|
||||
|
||||
common_pitfalls:
|
||||
- title: Forgetting Negative Numbers
|
||||
description: |
|
||||
The array can contain negative numbers (up to `-10^9`). The modulo operation behaves differently across languages for negative numbers.
|
||||
|
||||
In Python: `(-3) % 5 = 2` (correct for this problem)
|
||||
In Java/C++: `(-3) % 5 = -3` (problematic!)
|
||||
|
||||
For languages where `%` can return negative values, convert to positive: `((num % k) + k) % k`. This ensures all remainders fall in the range `[0, k-1]`.
|
||||
wrong_approach: "Using num % k directly without handling negatives"
|
||||
correct_approach: "Use ((num % k) + k) % k for positive remainders"
|
||||
|
||||
- title: Brute Force Pairing
|
||||
description: |
|
||||
Trying to actually find and match pairs using nested loops results in **O(n^2)** complexity. With `n = 10^5`, this means up to 10 billion operations, causing TLE.
|
||||
|
||||
The key insight is that we don't need to find the actual pairs — we only need to verify that valid pairings *exist* by checking remainder counts.
|
||||
wrong_approach: "Nested loops trying every possible pairing"
|
||||
correct_approach: "Count remainders and check complementary counts"
|
||||
|
||||
- title: Not Handling Remainder 0 Specially
|
||||
description: |
|
||||
Numbers with remainder `0` must pair with each other (no other remainder complements them to `k`). If there's an odd count of such numbers, pairing is impossible.
|
||||
|
||||
Similarly, when `k` is even, numbers with remainder `k/2` must pair with each other.
|
||||
wrong_approach: "Only checking complementary pairs without special case for 0"
|
||||
correct_approach: "Check that count[0] is even before checking other pairs"
|
||||
|
||||
key_takeaways:
|
||||
- "**Modular arithmetic insight**: Two numbers sum to a multiple of `k` when their remainders sum to `0` or `k`"
|
||||
- "**Count instead of pair**: When checking if valid pairings exist, counting elements in each category is often enough"
|
||||
- "**Handle edge cases**: Negative modulo and self-pairing remainders (0 and k/2) need special attention"
|
||||
- "**Pattern recognition**: This transforms a pairing problem into a counting/frequency problem"
|
||||
|
||||
time_complexity: "O(n). We iterate through the array once to build the frequency map, then iterate through at most `k` remainders to verify pairing is possible."
|
||||
space_complexity: "O(k). We store counts for up to `k` different remainders in our frequency map."
|
||||
|
||||
solutions:
|
||||
- approach_name: Remainder Counting
|
||||
is_optimal: true
|
||||
code: |
|
||||
def can_arrange(arr: list[int], k: int) -> bool:
|
||||
# Count frequency of each remainder
|
||||
remainder_count = [0] * k
|
||||
|
||||
for num in arr:
|
||||
# Handle negative numbers with proper modulo
|
||||
remainder = ((num % k) + k) % k
|
||||
remainder_count[remainder] += 1
|
||||
|
||||
# Check remainder 0: must have even count (pairs with itself)
|
||||
if remainder_count[0] % 2 != 0:
|
||||
return False
|
||||
|
||||
# Check complementary remainders
|
||||
for r in range(1, k // 2 + 1):
|
||||
if r == k - r:
|
||||
# Middle remainder (k/2 when k is even): must be even
|
||||
if remainder_count[r] % 2 != 0:
|
||||
return False
|
||||
else:
|
||||
# Complementary remainders must have equal counts
|
||||
if remainder_count[r] != remainder_count[k - r]:
|
||||
return False
|
||||
|
||||
return True
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Single pass through array plus O(k) remainder checks.
|
||||
|
||||
**Space Complexity:** O(k) — Array to store remainder counts.
|
||||
|
||||
We count how many numbers fall into each remainder bucket, then verify that complementary remainders have matching counts. This avoids the need to actually find pairs.
|
||||
|
||||
- approach_name: Hash Map Approach
|
||||
is_optimal: true
|
||||
code: |
|
||||
from collections import Counter
|
||||
|
||||
def can_arrange(arr: list[int], k: int) -> bool:
|
||||
# Count remainders using Counter
|
||||
remainder_count = Counter()
|
||||
|
||||
for num in arr:
|
||||
remainder = ((num % k) + k) % k
|
||||
remainder_count[remainder] += 1
|
||||
|
||||
# Verify pairing is possible
|
||||
for r in remainder_count:
|
||||
complement = (k - r) % k # Handles r=0 case elegantly
|
||||
|
||||
if remainder_count[r] != remainder_count[complement]:
|
||||
return False
|
||||
|
||||
return True
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Single pass to count, plus iteration over unique remainders.
|
||||
|
||||
**Space Complexity:** O(min(n, k)) — Hash map only stores remainders that actually appear.
|
||||
|
||||
This variant uses a hash map instead of a fixed-size array. The `(k - r) % k` formula elegantly handles the remainder 0 case (since `(k - 0) % k = 0`). For sparse arrays where most remainders don't appear, this uses less memory than the array approach.
|
||||
|
||||
- approach_name: Brute Force
|
||||
is_optimal: false
|
||||
code: |
|
||||
def can_arrange(arr: list[int], k: int) -> bool:
|
||||
n = len(arr)
|
||||
used = [False] * n
|
||||
|
||||
def backtrack(pairs_formed: int) -> bool:
|
||||
# All pairs formed successfully
|
||||
if pairs_formed == n // 2:
|
||||
return True
|
||||
|
||||
# Find first unused element
|
||||
first = -1
|
||||
for i in range(n):
|
||||
if not used[i]:
|
||||
first = i
|
||||
break
|
||||
|
||||
# Try pairing with every other unused element
|
||||
used[first] = True
|
||||
for j in range(first + 1, n):
|
||||
if not used[j] and (arr[first] + arr[j]) % k == 0:
|
||||
used[j] = True
|
||||
if backtrack(pairs_formed + 1):
|
||||
return True
|
||||
used[j] = False
|
||||
|
||||
used[first] = False
|
||||
return False
|
||||
|
||||
return backtrack(0)
|
||||
explanation: |
|
||||
**Time Complexity:** O(n!! ) — Double factorial due to pairing permutations.
|
||||
|
||||
**Space Complexity:** O(n) — Recursion stack and used array.
|
||||
|
||||
This backtracking approach tries all possible pairings. While correct, it's exponentially slow and will TLE on large inputs. Included to illustrate why the counting approach is necessary.
|
||||
@@ -0,0 +1,173 @@
|
||||
title: Check if Binary String Has at Most One Segment of Ones
|
||||
slug: check-if-binary-string-has-at-most-one-segment-of-ones
|
||||
difficulty: easy
|
||||
leetcode_id: 1784
|
||||
leetcode_url: https://leetcode.com/problems/check-if-binary-string-has-at-most-one-segment-of-ones/
|
||||
categories:
|
||||
- strings
|
||||
patterns:
|
||||
- two-pointers
|
||||
|
||||
description: |
|
||||
Given a binary string `s` **without leading zeros**, return `true` *if* `s` *contains **at most one contiguous segment of ones***. Otherwise, return `false`.
|
||||
|
||||
A contiguous segment of ones means all the `1`s in the string appear consecutively without any `0`s between them.
|
||||
|
||||
constraints: |
|
||||
- `1 <= s.length <= 100`
|
||||
- `s[i]` is either `'0'` or `'1'`
|
||||
- `s[0]` is `'1'` (no leading zeros)
|
||||
|
||||
examples:
|
||||
- input: 's = "1001"'
|
||||
output: "false"
|
||||
explanation: "The ones do not form a contiguous segment. There is a '1' at index 0, then zeros, then another '1' at index 3."
|
||||
- input: 's = "110"'
|
||||
output: "true"
|
||||
explanation: "The ones form a single contiguous segment at the beginning: '11'. The trailing '0' doesn't break continuity."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
The key insight comes from understanding what the constraint "no leading zeros" means for this problem.
|
||||
|
||||
Since the string **must** start with a `'1'`, all the ones in a valid string must appear at the very beginning. Think of it like this: once we see a `'0'`, we've "left" the segment of ones. If we ever see another `'1'` after that, we've found a second segment — which makes the answer `false`.
|
||||
|
||||
The critical observation is that a valid string looks like `1...10...0` (some ones followed by some zeros). An invalid string has the pattern `1...10...01...1` — where a `'1'` appears **after** a `'0'`.
|
||||
|
||||
This simplifies to checking for the substring `"01"` followed by any `'1'`. Even simpler: if `"01"` appears anywhere in the string, there will be a `'1'` before the `'0'` (since the string starts with `'1'`), so we just need to check if `"01"` is followed eventually by another `'1'`. The simplest check: does `"01"` appear in the string? If yes, and since we start with `'1'`, the only way to have `"01"` is if there are ones, then zeros, and since the string started with ones, any character after `"01"` that is `'1'` creates two segments.
|
||||
|
||||
Actually, the simplest observation: **if the pattern "01" appears in the string, check if any '1' comes after it**. But even simpler — since `s[0] = '1'`, we just need to check if the substring `"01"` appears in `s`. If it does, the string has the form `1...01...` and that trailing part could have more ones.
|
||||
|
||||
Wait, let's reconsider. The string `"110"` contains `"01"` (at positions 1-2). But the answer is `true`. So just checking for `"01"` isn't enough.
|
||||
|
||||
The real insight: we need to find if **after seeing a zero, we see another one**. This is equivalent to checking if the string contains `"01"` where there's a `'1'` somewhere after it. The simplest way: check if `"01"` appears in `s`. Since `s` has no leading zeros, `"01"` can only appear after some ones. After `"01"`, if there are more ones, we have two segments. If not (like `"110"`), we don't.
|
||||
|
||||
The cleanest formulation: **the string is invalid if and only if it contains the substring `"01"` and any `'1'` appears after the first occurrence of `"01"`**. This is equivalent to checking if the string contains `"01"` at any position that isn't the very end, OR if it ends with `"01"` we're fine.
|
||||
|
||||
The simplest check: **Does the string contain the pattern `"01"` where the `"1"` in `"01"` is followed by anything that contains a `'1'`?** This is just: `"01"` appears and isn't at the very end with nothing after, or what follows contains a `'1'`.
|
||||
|
||||
Simplest of all: **Check if `"01"` appears in the string. If so, check if there's any `'1'` after that position.** This can be simplified to: **the string is invalid if and only if it contains the substring `"01"` AND the character after `"01"` (or later) is a `'1'`**.
|
||||
|
||||
Actually the cleanest solution: **A string has two segments if and only if it contains `"01"` where a `'1'` appears anywhere after the `'0'` in `"01"`.** Since the string starts with `'1'`, this is equivalent to checking if the string contains `"01"` followed by any `'1'`. This is just: **does `"01"` appear anywhere except right before only zeros (or end of string)?**
|
||||
|
||||
The trick is realising that the string format `1+0+` (one or more ones, followed by zero or more zeros) is the only valid format. Any `'1'` appearing after a `'0'` is invalid. So: **return `"01"` not in `s`**? No, `"110"` has `"01"` at the end and is valid!
|
||||
|
||||
Final insight: Since the string cannot have leading zeros (starts with `'1'`), having one segment of ones means all ones are at the start. **The string is valid if and only if once we see a `'0'`, we never see a `'1'` again.** This is the pattern `"01"` followed by `'1'` — the substring `"01"` itself is okay, but not if followed by more ones.
|
||||
|
||||
The simplest code: check if `"01"` appears anywhere. If the part after the `"01"` contains a `'1'`, return `false`. Since we only care about the first `"01"`, we can just check: **does the string contain the substring `"01"` where a `'1'` follows it?** This is equivalent to `"01"` being present AND not being the last two characters (since after them comes nothing or only zeros).
|
||||
|
||||
Actually — just check if `'1'` appears after ANY `'0'`. If we ever see a `'0'` followed eventually by a `'1'`, that's two segments. This is precisely the check: **return `"01"` not in s** BUT we need `"01"` where the `'1'` is after the `'0'`. Wait, `"01"` is literally `'0'` followed by `'1'`.
|
||||
|
||||
So the answer is: **return `"01"` not in s**. Let's verify:
|
||||
- `"1001"`: contains `"01"` at position 2-3? Actually position 2 is `'0'`, position 3 is `'1'`. Yes, `"01"` is at index 2. So return `false`. Correct!
|
||||
- `"110"`: contains `"01"` at position 1-2. So return `false`. But expected is `true`! So this approach is wrong.
|
||||
|
||||
Let me re-examine. `"110"` has chars `'1'` at 0, `'1'` at 1, `'0'` at 2. So we have `"11"` then `"10"`. There's no `"01"` substring. So `"01" not in "110"` is `True`. Return `True`. Correct!
|
||||
|
||||
Let me re-check `"1001"`: chars are `'1'`, `'0'`, `'0'`, `'1'`. The substrings of length 2 are `"10"`, `"00"`, `"01"`. Yes, `"01"` appears! So return `False`. Correct!
|
||||
|
||||
The solution is simply: **return `"01"` not in s**.
|
||||
|
||||
approach: |
|
||||
We solve this using a **Substring Check** approach:
|
||||
|
||||
**Step 1: Understand the pattern**
|
||||
|
||||
- A binary string with no leading zeros starts with `'1'`
|
||||
- For all ones to be contiguous, they must all be at the beginning
|
||||
- The valid pattern is: `1...10...0` (ones followed by zeros)
|
||||
- The invalid pattern is: `1...10...01` (a `'1'` appearing after a `'0'`)
|
||||
|
||||
|
||||
|
||||
**Step 2: Identify the key insight**
|
||||
|
||||
- If a `'0'` is ever followed directly by a `'1'` (the substring `"01"`), there are multiple segments
|
||||
- If `"01"` never appears, all ones are contiguous at the start
|
||||
|
||||
|
||||
|
||||
**Step 3: Check for the pattern**
|
||||
|
||||
- Simply check if the substring `"01"` exists in `s`
|
||||
- If it does, return `false` (multiple segments)
|
||||
- If it doesn't, return `true` (at most one segment)
|
||||
|
||||
|
||||
|
||||
This works because `"01"` appearing means we transitioned from zeros back to ones, which is only possible if there's a gap in the ones.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Counting Segments with State Machine
|
||||
description: |
|
||||
A common over-complication is to build a state machine or counter to track "entering" and "leaving" segments of ones.
|
||||
|
||||
While this works, it's more complex than necessary. The substring check `"01" in s` captures the exact condition in one operation.
|
||||
wrong_approach: "Tracking state transitions and counting segments"
|
||||
correct_approach: "Check for '01' substring"
|
||||
|
||||
- title: Misunderstanding the Leading Zero Constraint
|
||||
description: |
|
||||
Some solutions forget that `s[0]` is guaranteed to be `'1'`. This constraint is crucial — it means we don't need to handle cases like `"0110"`.
|
||||
|
||||
Without this constraint, we'd need additional logic. With it, any `"01"` substring definitively indicates multiple segments.
|
||||
wrong_approach: "Adding unnecessary checks for leading zeros"
|
||||
correct_approach: "Trust the constraint that s[0] = '1'"
|
||||
|
||||
- title: Off-by-One in Manual Iteration
|
||||
description: |
|
||||
When manually iterating, it's easy to make off-by-one errors when checking adjacent characters.
|
||||
|
||||
```python
|
||||
# Wrong: might miss the last pair
|
||||
for i in range(len(s) - 1): # Correct, but easy to write range(len(s)) by mistake
|
||||
```
|
||||
|
||||
Using the built-in substring check avoids these issues entirely.
|
||||
|
||||
key_takeaways:
|
||||
- "**Pattern recognition**: Translate the problem into a simple substring or pattern check when possible"
|
||||
- "**Use constraints**: The 'no leading zeros' constraint simplifies the problem significantly"
|
||||
- "**Built-in operations**: Python's `in` operator for substrings is both readable and efficient"
|
||||
- "**Similar problems**: This pattern of checking for forbidden substrings applies to many string validation problems"
|
||||
|
||||
time_complexity: "O(n). The substring check `\"01\" in s` scans the string once."
|
||||
space_complexity: "O(1). We only perform a containment check without allocating additional data structures."
|
||||
|
||||
solutions:
|
||||
- approach_name: Substring Check
|
||||
is_optimal: true
|
||||
code: |
|
||||
def check_ones_segment(s: str) -> bool:
|
||||
# If '01' appears, a '1' comes after a '0' — meaning multiple segments
|
||||
# If '01' doesn't appear, all '1's are contiguous at the start
|
||||
return "01" not in s
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Single pass through the string for substring search.
|
||||
|
||||
**Space Complexity:** O(1) — No additional space used.
|
||||
|
||||
This elegant one-liner leverages the constraint that the string has no leading zeros. Since the string starts with `'1'`, the only way to have the substring `"01"` is if there's a `'0'` followed by a `'1'` — which means the ones are not contiguous.
|
||||
|
||||
- approach_name: Linear Scan with Flag
|
||||
is_optimal: false
|
||||
code: |
|
||||
def check_ones_segment(s: str) -> bool:
|
||||
seen_zero = False
|
||||
|
||||
for char in s:
|
||||
if char == '0':
|
||||
# We've left the initial segment of ones
|
||||
seen_zero = True
|
||||
elif seen_zero:
|
||||
# Found a '1' after seeing a '0' — two segments
|
||||
return False
|
||||
|
||||
# Never found a '1' after a '0'
|
||||
return True
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Single pass through the string.
|
||||
|
||||
**Space Complexity:** O(1) — Only one boolean flag used.
|
||||
|
||||
This approach explicitly tracks whether we've seen a `'0'`. Once we have, any subsequent `'1'` means there are multiple segments. While correct and efficient, the substring check approach is more concise.
|
||||
@@ -0,0 +1,163 @@
|
||||
title: Check if Every Row and Column Contains All Numbers
|
||||
slug: check-if-every-row-and-column-contains-all-numbers
|
||||
difficulty: easy
|
||||
leetcode_id: 2133
|
||||
leetcode_url: https://leetcode.com/problems/check-if-every-row-and-column-contains-all-numbers/
|
||||
categories:
|
||||
- arrays
|
||||
- hash-tables
|
||||
patterns:
|
||||
- matrix-traversal
|
||||
|
||||
description: |
|
||||
An `n x n` matrix is **valid** if every row and every column contains **all** the integers from `1` to `n` (**inclusive**).
|
||||
|
||||
Given an `n x n` integer matrix `matrix`, return `true` *if the matrix is **valid***. Otherwise, return `false`.
|
||||
|
||||
constraints: |
|
||||
- `n == matrix.length == matrix[i].length`
|
||||
- `1 <= n <= 100`
|
||||
- `1 <= matrix[i][j] <= n`
|
||||
|
||||
examples:
|
||||
- input: "matrix = [[1,2,3],[3,1,2],[2,3,1]]"
|
||||
output: "true"
|
||||
explanation: "In this case, n = 3, and every row and column contains the numbers 1, 2, and 3. Hence, we return true."
|
||||
- input: "matrix = [[1,1,1],[1,2,3],[1,2,3]]"
|
||||
output: "false"
|
||||
explanation: "In this case, n = 3, but the first row and the first column do not contain the numbers 2 or 3. Hence, we return false."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Think of this problem like checking a **simplified Sudoku**. In a valid Sudoku, each row and column must contain every number from 1 to 9 exactly once. Here, we have the same requirement for numbers 1 to n.
|
||||
|
||||
The core insight is that if a row (or column) of size `n` must contain all numbers from 1 to n, then each number must appear **exactly once**. This means:
|
||||
- No duplicates are allowed
|
||||
- No numbers outside the range `[1, n]` are allowed (though the constraints guarantee this)
|
||||
|
||||
A set is the perfect data structure for this check. When you add `n` elements to a set, the set's size will be `n` only if all elements were unique. If there were any duplicates, the size would be smaller.
|
||||
|
||||
By checking each row and each column against this property, we can determine if the matrix is valid.
|
||||
|
||||
approach: |
|
||||
We solve this using a **Set-Based Validation** approach:
|
||||
|
||||
**Step 1: Determine the matrix size**
|
||||
|
||||
- Get `n` from the length of the matrix
|
||||
- This tells us each row and column must contain exactly the numbers `1` through `n`
|
||||
|
||||
|
||||
|
||||
**Step 2: Validate each row**
|
||||
|
||||
- For each row in the matrix, convert it to a set
|
||||
- Check if the set equals `{1, 2, 3, ..., n}`
|
||||
- If any row fails this check, return `false` immediately
|
||||
|
||||
|
||||
|
||||
**Step 3: Validate each column**
|
||||
|
||||
- For each column index `j`, collect all elements `matrix[i][j]` for `i` from `0` to `n-1`
|
||||
- Convert this column to a set
|
||||
- Check if the set equals `{1, 2, 3, ..., n}`
|
||||
- If any column fails this check, return `false` immediately
|
||||
|
||||
|
||||
|
||||
**Step 4: Return the result**
|
||||
|
||||
- If all rows and columns pass validation, return `true`
|
||||
|
||||
common_pitfalls:
|
||||
- title: Checking Only Rows or Only Columns
|
||||
description: |
|
||||
A matrix might have valid rows but invalid columns (or vice versa).
|
||||
|
||||
For example, `[[1,2],[1,2]]` has valid rows (both contain `{1, 2}`), but the first column is `{1, 1}` and the second is `{2, 2}` — neither column is valid.
|
||||
|
||||
Always check **both** rows and columns before returning `true`.
|
||||
wrong_approach: "Only validating rows"
|
||||
correct_approach: "Validate both rows and columns"
|
||||
|
||||
- title: Counting Instead of Using Sets
|
||||
description: |
|
||||
You might try counting occurrences and checking if each number appears exactly once. While this works, it's more complex and error-prone.
|
||||
|
||||
Using a set is cleaner: if the set of elements equals `{1, 2, ..., n}`, the row/column is valid. This automatically handles duplicate detection and range validation in one check.
|
||||
wrong_approach: "Manual counting with arrays"
|
||||
correct_approach: "Set comparison for cleaner validation"
|
||||
|
||||
- title: Not Failing Fast
|
||||
description: |
|
||||
Some solutions check all rows and columns before deciding validity. This is wasteful — as soon as you find one invalid row or column, you know the entire matrix is invalid.
|
||||
|
||||
Return `false` immediately when validation fails to optimise performance.
|
||||
wrong_approach: "Check everything, then decide"
|
||||
correct_approach: "Return false at first failure"
|
||||
|
||||
key_takeaways:
|
||||
- "**Sets for uniqueness**: When checking for duplicates or that all elements in a range are present, sets provide an elegant O(n) solution"
|
||||
- "**Matrix traversal pattern**: Accessing columns requires iterating with a fixed column index: `matrix[i][col]` for all rows `i`"
|
||||
- "**Fail fast principle**: Return early when you find invalid data rather than completing all checks unnecessarily"
|
||||
- "**Simplified Sudoku**: This problem is a building block for understanding more complex grid validation like Sudoku"
|
||||
|
||||
time_complexity: "O(n^2). We visit each cell exactly twice — once when checking its row and once when checking its column."
|
||||
space_complexity: "O(n). We use a set that holds at most `n` elements at any time for validation."
|
||||
|
||||
solutions:
|
||||
- approach_name: Set-Based Validation
|
||||
is_optimal: true
|
||||
code: |
|
||||
def check_valid(matrix: list[list[int]]) -> bool:
|
||||
n = len(matrix)
|
||||
# The set of numbers that must appear in each row/column
|
||||
expected = set(range(1, n + 1))
|
||||
|
||||
# Check each row
|
||||
for row in matrix:
|
||||
if set(row) != expected:
|
||||
return False
|
||||
|
||||
# Check each column
|
||||
for col in range(n):
|
||||
# Collect all elements in this column
|
||||
column_set = {matrix[row][col] for row in range(n)}
|
||||
if column_set != expected:
|
||||
return False
|
||||
|
||||
return True
|
||||
explanation: |
|
||||
**Time Complexity:** O(n^2) — We iterate through all n^2 elements twice (once for rows, once for columns).
|
||||
|
||||
**Space Complexity:** O(n) — The sets we create hold at most n elements.
|
||||
|
||||
We create the expected set `{1, 2, ..., n}` once, then compare each row and column against it. Using set comprehension for columns keeps the code clean and Pythonic.
|
||||
|
||||
- approach_name: Early Termination with Size Check
|
||||
is_optimal: true
|
||||
code: |
|
||||
def check_valid(matrix: list[list[int]]) -> bool:
|
||||
n = len(matrix)
|
||||
|
||||
# Check rows - set size must equal n (all unique 1 to n)
|
||||
for row in matrix:
|
||||
if len(set(row)) != n:
|
||||
return False
|
||||
|
||||
# Check columns
|
||||
for col in range(n):
|
||||
column_values = set()
|
||||
for row in range(n):
|
||||
column_values.add(matrix[row][col])
|
||||
if len(column_values) != n:
|
||||
return False
|
||||
|
||||
return True
|
||||
explanation: |
|
||||
**Time Complexity:** O(n^2) — Same as the previous approach.
|
||||
|
||||
**Space Complexity:** O(n) — Set holds at most n elements.
|
||||
|
||||
This variant uses size checking instead of set equality. Since the constraints guarantee `1 <= matrix[i][j] <= n`, if we have `n` unique values, they must be exactly `{1, 2, ..., n}`. This avoids creating the expected set but relies on the constraint being enforced.
|
||||
177
backend/data/questions/check-if-it-is-a-good-array.yaml
Normal file
177
backend/data/questions/check-if-it-is-a-good-array.yaml
Normal file
@@ -0,0 +1,177 @@
|
||||
title: Check If It Is a Good Array
|
||||
slug: check-if-it-is-a-good-array
|
||||
difficulty: hard
|
||||
leetcode_id: 1250
|
||||
leetcode_url: https://leetcode.com/problems/check-if-it-is-a-good-array/
|
||||
categories:
|
||||
- arrays
|
||||
- math
|
||||
patterns:
|
||||
- greedy
|
||||
|
||||
description: |
|
||||
Given an array `nums` of positive integers. Your task is to select some subset of `nums`, multiply each element by an integer and add all these numbers.
|
||||
|
||||
The array is said to be **good** if you can obtain a sum of `1` from the array by any possible subset and multiplicand.
|
||||
|
||||
Return `True` if the array is **good** otherwise return `False`.
|
||||
|
||||
constraints: |
|
||||
- `1 <= nums.length <= 10^5`
|
||||
- `1 <= nums[i] <= 10^9`
|
||||
|
||||
examples:
|
||||
- input: "nums = [12,5,7,23]"
|
||||
output: "true"
|
||||
explanation: "Pick numbers 5 and 7. 5*3 + 7*(-2) = 1"
|
||||
- input: "nums = [29,6,10]"
|
||||
output: "true"
|
||||
explanation: "Pick numbers 29, 6 and 10. 29*1 + 6*(-3) + 10*(-1) = 1"
|
||||
- input: "nums = [3,6]"
|
||||
output: "false"
|
||||
explanation: "Both 3 and 6 are divisible by 3, so any linear combination will also be divisible by 3, never equal to 1."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
This problem might look intimidating at first — selecting subsets, multiplying by arbitrary integers, and summing to exactly `1`. But there's a beautiful mathematical theorem that makes this trivial once you recognise it.
|
||||
|
||||
**Bézout's Identity** states that for any two integers `a` and `b`, there exist integers `x` and `y` such that `ax + by = gcd(a, b)`. This extends to any number of integers: given `a₁, a₂, ..., aₙ`, we can find integers `x₁, x₂, ..., xₙ` such that `a₁x₁ + a₂x₂ + ... + aₙxₙ = gcd(a₁, a₂, ..., aₙ)`.
|
||||
|
||||
The key insight is that `1` can be expressed as such a linear combination **if and only if** the GCD of all numbers is `1`. Why? Because:
|
||||
- Any linear combination of the numbers must be divisible by their GCD
|
||||
- So if the GCD is greater than `1`, we can never reach `1`
|
||||
- And if the GCD equals `1`, Bézout's identity guarantees we *can* reach `1`
|
||||
|
||||
Think of it like this: the GCD represents the "smallest building block" that all numbers share. If that building block is `1`, we can construct any integer (including `1`). If it's larger, we can only construct multiples of that larger value.
|
||||
|
||||
approach: |
|
||||
With Bézout's Identity, the solution becomes remarkably simple:
|
||||
|
||||
**Step 1: Initialise the running GCD**
|
||||
|
||||
- `result`: Set to the first element of the array, or we can start with `0` (since `gcd(0, x) = x`)
|
||||
|
||||
|
||||
|
||||
**Step 2: Iterate through the array**
|
||||
|
||||
- For each number in `nums`, compute `gcd(result, nums[i])`
|
||||
- Update `result` with this new GCD
|
||||
- **Early termination**: If `result` becomes `1` at any point, we can immediately return `True` — the GCD can never increase, and `1` is the smallest positive integer
|
||||
|
||||
|
||||
|
||||
**Step 3: Return the result**
|
||||
|
||||
- If the final GCD equals `1`, return `True`
|
||||
- Otherwise, return `False`
|
||||
|
||||
|
||||
|
||||
The early termination optimisation is valuable because once we find two coprime numbers (GCD = 1), no further numbers can change this.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Overthinking the Subset Selection
|
||||
description: |
|
||||
The problem statement mentions "select some subset" and "multiply each element by an integer." This naturally leads to thinking about combinatorial approaches — trying different subsets, solving systems of equations, or using dynamic programming.
|
||||
|
||||
However, this is a red herring. The mathematical insight (Bézout's Identity) tells us that the *existence* of such integers is guaranteed when GCD = 1. We don't need to find the actual coefficients, just verify the GCD condition.
|
||||
wrong_approach: "Dynamic programming or subset enumeration"
|
||||
correct_approach: "Simply compute the GCD of all elements"
|
||||
|
||||
- title: Missing the GCD Connection
|
||||
description: |
|
||||
If you don't recognise this as a number theory problem, you might try brute force approaches that will time out. With `n` up to `10^5` elements and values up to `10^9`, any exponential or polynomial approach in terms of values won't work.
|
||||
|
||||
The GCD approach is O(n log(max_value)), which handles even the largest inputs efficiently.
|
||||
wrong_approach: "Brute force subset enumeration"
|
||||
correct_approach: "Recognise the Bézout's Identity pattern"
|
||||
|
||||
- title: Not Using Early Termination
|
||||
description: |
|
||||
While not strictly necessary for correctness, failing to check if the GCD becomes `1` early means unnecessary computation. Once two numbers are coprime, the overall GCD is locked at `1`.
|
||||
|
||||
For example, with `[1000000, 7, 3, 3, 3, ...]` (many 3s), we find GCD = 1 after just the first two elements. Without early termination, we'd wastefully compute GCDs with all remaining elements.
|
||||
wrong_approach: "Always compute GCD with all elements"
|
||||
correct_approach: "Return True immediately when GCD reaches 1"
|
||||
|
||||
key_takeaways:
|
||||
- "**Bézout's Identity**: A linear combination of integers can equal `d` if and only if `d` is a multiple of their GCD — the key mathematical insight"
|
||||
- "**Recognise number theory patterns**: Problems involving linear combinations and integer solutions often reduce to GCD checks"
|
||||
- "**Early termination**: When computing running GCD, stop as soon as you reach `1` — it can never decrease"
|
||||
- "**Don't overcomplicate**: What looks like a hard combinatorial problem becomes trivial with the right mathematical perspective"
|
||||
|
||||
time_complexity: "O(n log M). We iterate through `n` elements, and each GCD computation takes O(log M) where M is the maximum element value (using Euclidean algorithm)."
|
||||
space_complexity: "O(1). We only track a single running GCD value regardless of input size."
|
||||
|
||||
solutions:
|
||||
- approach_name: GCD with Early Termination
|
||||
is_optimal: true
|
||||
code: |
|
||||
from math import gcd
|
||||
from functools import reduce
|
||||
|
||||
def is_good_array(nums: list[int]) -> bool:
|
||||
# Bézout's Identity: can reach 1 iff GCD of all elements is 1
|
||||
# Use reduce to compute GCD across the entire array
|
||||
return reduce(gcd, nums) == 1
|
||||
explanation: |
|
||||
**Time Complexity:** O(n log M) — n elements, each GCD takes O(log M) time.
|
||||
|
||||
**Space Complexity:** O(1) — Only stores the running GCD.
|
||||
|
||||
Python's `reduce` applies `gcd` cumulatively: `gcd(gcd(gcd(nums[0], nums[1]), nums[2]), ...)`. By Bézout's Identity, the array is "good" exactly when this overall GCD equals 1.
|
||||
|
||||
- approach_name: Iterative GCD with Explicit Early Exit
|
||||
is_optimal: true
|
||||
code: |
|
||||
from math import gcd
|
||||
|
||||
def is_good_array(nums: list[int]) -> bool:
|
||||
# Start with first element as our running GCD
|
||||
result = nums[0]
|
||||
|
||||
for num in nums[1:]:
|
||||
# Update running GCD
|
||||
result = gcd(result, num)
|
||||
|
||||
# Early termination: GCD of 1 can never decrease
|
||||
if result == 1:
|
||||
return True
|
||||
|
||||
# Check if final GCD is 1
|
||||
return result == 1
|
||||
explanation: |
|
||||
**Time Complexity:** O(n log M) — worst case iterates all elements, but often exits early.
|
||||
|
||||
**Space Complexity:** O(1) — Single variable tracks the running GCD.
|
||||
|
||||
This version explicitly shows the early termination optimisation. Once we find two coprime numbers (GCD = 1), no additional elements can change this fact. This is particularly effective when the array contains diverse numbers likely to be coprime.
|
||||
|
||||
- approach_name: Mathematical Proof Verification
|
||||
is_optimal: false
|
||||
code: |
|
||||
from math import gcd
|
||||
|
||||
def is_good_array(nums: list[int]) -> bool:
|
||||
# Compute GCD of all elements step by step
|
||||
overall_gcd = 0 # gcd(0, x) = x, so this works as identity
|
||||
|
||||
for num in nums:
|
||||
overall_gcd = gcd(overall_gcd, num)
|
||||
|
||||
# Optimisation: exit early if GCD reaches 1
|
||||
if overall_gcd == 1:
|
||||
return True
|
||||
|
||||
# By Bézout's Identity:
|
||||
# ax + by = gcd(a,b) for some integers x, y
|
||||
# Extended to n numbers: we can reach gcd(nums) via linear combination
|
||||
# Therefore, we can reach 1 iff gcd(all nums) == 1
|
||||
return overall_gcd == 1
|
||||
explanation: |
|
||||
**Time Complexity:** O(n log M) — Same as optimal, with more explicit comments.
|
||||
|
||||
**Space Complexity:** O(1) — Only the running GCD variable.
|
||||
|
||||
This version documents the mathematical reasoning inline. Starting with `0` works because `gcd(0, x) = x`, making it a valid identity element. The extended Euclidean algorithm guarantees that if GCD = 1, integer coefficients exist to form the linear combination.
|
||||
156
backend/data/questions/check-if-it-is-a-straight-line.yaml
Normal file
156
backend/data/questions/check-if-it-is-a-straight-line.yaml
Normal file
@@ -0,0 +1,156 @@
|
||||
title: Check If It Is a Straight Line
|
||||
slug: check-if-it-is-a-straight-line
|
||||
difficulty: easy
|
||||
leetcode_id: 1232
|
||||
leetcode_url: https://leetcode.com/problems/check-if-it-is-a-straight-line/
|
||||
categories:
|
||||
- arrays
|
||||
- math
|
||||
patterns:
|
||||
- two-pointers
|
||||
|
||||
description: |
|
||||
You are given an array `coordinates`, where `coordinates[i] = [x, y]` represents the coordinate of a point. Check if these points make a straight line in the XY plane.
|
||||
|
||||
constraints: |
|
||||
- `2 <= coordinates.length <= 1000`
|
||||
- `coordinates[i].length == 2`
|
||||
- `-10^4 <= coordinates[i][0], coordinates[i][1] <= 10^4`
|
||||
- `coordinates` contains no duplicate points
|
||||
|
||||
examples:
|
||||
- input: "coordinates = [[1,2],[2,3],[3,4],[4,5],[5,6],[6,7]]"
|
||||
output: "true"
|
||||
explanation: "All points lie on the line y = x + 1."
|
||||
- input: "coordinates = [[1,1],[2,2],[3,4],[4,5],[5,6],[7,7]]"
|
||||
output: "false"
|
||||
explanation: "The point [3,4] does not lie on the line formed by [1,1] and [2,2]."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Imagine drawing a line through the first two points on a graph. For all the remaining points to lie on that same line, they must all share the **same slope** relative to the starting point.
|
||||
|
||||
The slope between two points `(x1, y1)` and `(x2, y2)` is calculated as `(y2 - y1) / (x2 - x1)`. If every point has the same slope relative to the first point, they're all collinear (on the same straight line).
|
||||
|
||||
However, there's a catch: dividing by zero when `x2 - x1 = 0` (a vertical line) or dealing with floating-point precision issues. The elegant solution is to use **cross multiplication** to avoid division entirely.
|
||||
|
||||
Instead of comparing `(y2 - y1) / (x2 - x1) == (y3 - y1) / (x3 - x1)`, we rearrange to: `(y2 - y1) * (x3 - x1) == (y3 - y1) * (x2 - x1)`. This works for all cases, including vertical lines.
|
||||
|
||||
approach: |
|
||||
We solve this using a **Cross Product** approach to check collinearity:
|
||||
|
||||
**Step 1: Get the reference direction**
|
||||
|
||||
- Calculate `dx`: the difference in x-coordinates between the first two points (`x1 - x0`)
|
||||
- Calculate `dy`: the difference in y-coordinates between the first two points (`y1 - y0`)
|
||||
- This gives us the "direction vector" of the line
|
||||
|
||||
|
||||
|
||||
**Step 2: Check each subsequent point**
|
||||
|
||||
- For each point from index `2` onwards, calculate its displacement from the first point
|
||||
- Compute `dx_i = x_i - x0` and `dy_i = y_i - y0`
|
||||
- Check if the cross product equals zero: `dy * dx_i == dx * dy_i`
|
||||
- If not equal, the point is not on the line — return `false`
|
||||
|
||||
|
||||
|
||||
**Step 3: Return the result**
|
||||
|
||||
- If all points pass the cross product check, return `true`
|
||||
|
||||
|
||||
|
||||
The cross product check works because two vectors are parallel (and thus collinear with the origin) if and only if their cross product is zero.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Division-Based Slope Comparison
|
||||
description: |
|
||||
A naive approach might compute slopes using division:
|
||||
```python
|
||||
slope = (y2 - y1) / (x2 - x1)
|
||||
```
|
||||
This fails for vertical lines where `x2 - x1 = 0`, causing a division by zero error. Additionally, floating-point arithmetic can introduce precision errors when comparing slopes.
|
||||
|
||||
Using cross multiplication avoids both issues entirely.
|
||||
wrong_approach: "Divide to get slope, compare with equality"
|
||||
correct_approach: "Use cross product: dy * dx_i == dx * dy_i"
|
||||
|
||||
- title: Forgetting Edge Cases
|
||||
description: |
|
||||
With only 2 points, any two distinct points form a line. The algorithm handles this naturally since there are no additional points to check beyond the reference pair.
|
||||
|
||||
Some implementations might incorrectly return `false` for 2 points or fail to handle negative coordinates properly.
|
||||
wrong_approach: "Special-case 2 points incorrectly"
|
||||
correct_approach: "The general algorithm works for 2+ points"
|
||||
|
||||
- title: Using Wrong Reference Point
|
||||
description: |
|
||||
When checking collinearity, always compute displacements from a consistent reference point (typically the first point). Using different reference points for different comparisons can lead to incorrect results due to accumulated errors or logic mistakes.
|
||||
wrong_approach: "Compare adjacent pairs independently"
|
||||
correct_approach: "Compare all points to the same reference (first point)"
|
||||
|
||||
key_takeaways:
|
||||
- "**Cross product for collinearity**: When checking if points are on the same line, use cross multiplication `(dy * dx_i == dx * dy_i)` to avoid division by zero and floating-point precision issues"
|
||||
- "**Avoid division when possible**: In geometry problems, reformulating division as multiplication often leads to more robust solutions"
|
||||
- "**Vector thinking**: Treat the difference between two points as a direction vector — this perspective simplifies many geometry problems"
|
||||
- "**Foundation for line algorithms**: This technique extends to problems involving line detection, convex hulls, and computational geometry"
|
||||
|
||||
time_complexity: "O(n). We iterate through each point exactly once, where `n` is the number of points."
|
||||
space_complexity: "O(1). We only use a constant number of variables (`dx`, `dy`, `dx_i`, `dy_i`) regardless of input size."
|
||||
|
||||
solutions:
|
||||
- approach_name: Cross Product
|
||||
is_optimal: true
|
||||
code: |
|
||||
def check_straight_line(coordinates: list[list[int]]) -> bool:
|
||||
# Get the direction vector from first two points
|
||||
x0, y0 = coordinates[0]
|
||||
x1, y1 = coordinates[1]
|
||||
dx = x1 - x0 # Change in x for reference direction
|
||||
dy = y1 - y0 # Change in y for reference direction
|
||||
|
||||
# Check if each subsequent point lies on the same line
|
||||
for i in range(2, len(coordinates)):
|
||||
xi, yi = coordinates[i]
|
||||
# Displacement from first point to current point
|
||||
dx_i = xi - x0
|
||||
dy_i = yi - y0
|
||||
|
||||
# Cross product check: if not zero, points aren't collinear
|
||||
# This is equivalent to checking if slopes are equal
|
||||
# but avoids division (and thus division by zero)
|
||||
if dy * dx_i != dx * dy_i:
|
||||
return False
|
||||
|
||||
return True
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Single pass through the coordinates array.
|
||||
|
||||
**Space Complexity:** O(1) — Only uses a fixed number of variables.
|
||||
|
||||
We compute the reference direction from the first two points, then verify each subsequent point lies on the same line using the cross product. If `dy * dx_i != dx * dy_i` for any point, the vectors aren't parallel, meaning the points aren't collinear.
|
||||
|
||||
- approach_name: Slope Comparison (with edge case handling)
|
||||
is_optimal: false
|
||||
code: |
|
||||
def check_straight_line(coordinates: list[list[int]]) -> bool:
|
||||
x0, y0 = coordinates[0]
|
||||
x1, y1 = coordinates[1]
|
||||
|
||||
for i in range(2, len(coordinates)):
|
||||
xi, yi = coordinates[i]
|
||||
# Check using cross multiplication to avoid division
|
||||
# (y1 - y0) / (x1 - x0) == (yi - y0) / (xi - x0)
|
||||
# becomes: (y1 - y0) * (xi - x0) == (yi - y0) * (x1 - x0)
|
||||
if (y1 - y0) * (xi - x0) != (yi - y0) * (x1 - x0):
|
||||
return False
|
||||
|
||||
return True
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Single pass through the array.
|
||||
|
||||
**Space Complexity:** O(1) — Constant extra space.
|
||||
|
||||
This is mathematically equivalent to the cross product approach but written in a form that more clearly shows the slope comparison origin. Both avoid division by using cross multiplication.
|
||||
167
backend/data/questions/check-if-matrix-is-x-matrix.yaml
Normal file
167
backend/data/questions/check-if-matrix-is-x-matrix.yaml
Normal file
@@ -0,0 +1,167 @@
|
||||
title: Check if Matrix Is X-Matrix
|
||||
slug: check-if-matrix-is-x-matrix
|
||||
difficulty: easy
|
||||
leetcode_id: 2319
|
||||
leetcode_url: https://leetcode.com/problems/check-if-matrix-is-x-matrix/
|
||||
categories:
|
||||
- arrays
|
||||
- math
|
||||
patterns:
|
||||
- matrix-traversal
|
||||
|
||||
description: |
|
||||
A square matrix is said to be an **X-Matrix** if **both** of the following conditions hold:
|
||||
|
||||
1. All the elements in the diagonals of the matrix are **non-zero**.
|
||||
2. All other elements are `0`.
|
||||
|
||||
Given a 2D integer array `grid` of size `n x n` representing a square matrix, return `true` *if* `grid` *is an X-Matrix*. Otherwise, return `false`.
|
||||
|
||||
constraints: |
|
||||
- `n == grid.length == grid[i].length`
|
||||
- `3 <= n <= 100`
|
||||
- `0 <= grid[i][j] <= 10^5`
|
||||
|
||||
examples:
|
||||
- input: "grid = [[2,0,0,1],[0,3,1,0],[0,5,2,0],[4,0,0,2]]"
|
||||
output: "true"
|
||||
explanation: "The diagonal elements (positions where i == j or i + j == n - 1) are all non-zero: 2, 1, 3, 1, 5, 2, 4, 2. All other elements are 0. Thus, grid is an X-Matrix."
|
||||
- input: "grid = [[5,7,0],[0,3,1],[0,5,0]]"
|
||||
output: "false"
|
||||
explanation: "The element at position (0,1) is 7, which should be 0 since it's not on a diagonal. Also, position (1,2) is 1 instead of 0. Thus, grid is not an X-Matrix."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Imagine drawing a large "X" across a square grid. The "X" touches the four corners and crosses in the center. These positions form the **two diagonals** of the matrix:
|
||||
|
||||
- **Primary diagonal**: runs from top-left to bottom-right (where row index equals column index: `i == j`)
|
||||
- **Anti-diagonal**: runs from top-right to bottom-left (where row plus column equals `n - 1`: `i + j == n - 1`)
|
||||
|
||||
Think of it like this: you're a quality inspector checking each cell of the grid. At every position, you need to answer one question: "Am I on a diagonal?" If yes, the value must be non-zero. If no, the value must be zero.
|
||||
|
||||
The key insight is that determining whether a position `(i, j)` lies on a diagonal is a simple mathematical check — no need to pre-compute or store the diagonal positions. This allows us to verify the X-Matrix property in a single pass through the grid.
|
||||
|
||||
approach: |
|
||||
We solve this using a **Single Pass Matrix Traversal**:
|
||||
|
||||
**Step 1: Iterate through every cell**
|
||||
|
||||
- Use nested loops to visit each position `(i, j)` in the grid
|
||||
- For each cell, determine if it lies on a diagonal
|
||||
|
||||
|
||||
|
||||
**Step 2: Check diagonal membership**
|
||||
|
||||
- A cell `(i, j)` is on the primary diagonal if `i == j`
|
||||
- A cell `(i, j)` is on the anti-diagonal if `i + j == n - 1`
|
||||
- Combined: a cell is on *some* diagonal if `i == j` or `i + j == n - 1`
|
||||
|
||||
|
||||
|
||||
**Step 3: Validate the cell value**
|
||||
|
||||
- If the cell is on a diagonal: check that `grid[i][j] != 0`
|
||||
- If the cell is NOT on a diagonal: check that `grid[i][j] == 0`
|
||||
- If any check fails, immediately return `false`
|
||||
|
||||
|
||||
|
||||
**Step 4: Return the result**
|
||||
|
||||
- If we complete the traversal without finding any violations, return `true`
|
||||
|
||||
|
||||
|
||||
This approach works because we exhaustively check every cell exactly once, ensuring both conditions of the X-Matrix definition are satisfied.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Forgetting the Anti-Diagonal
|
||||
description: |
|
||||
A common mistake is only checking the primary diagonal (`i == j`) and forgetting that the anti-diagonal (`i + j == n - 1`) is also part of the "X" shape.
|
||||
|
||||
For example, in a 3x3 grid, position `(0, 2)` and `(2, 0)` are on the anti-diagonal but not the primary diagonal. Missing this check would incorrectly require these cells to be zero.
|
||||
wrong_approach: "Only checking i == j for diagonals"
|
||||
correct_approach: "Check both i == j and i + j == n - 1"
|
||||
|
||||
- title: Off-By-One in Anti-Diagonal Check
|
||||
description: |
|
||||
The anti-diagonal condition is `i + j == n - 1`, not `i + j == n`. Remember that indices are 0-based, so for an `n x n` matrix, the anti-diagonal connects positions like `(0, n-1)`, `(1, n-2)`, ..., `(n-1, 0)`.
|
||||
|
||||
If you use `i + j == n`, you'll be checking positions that don't exist or missing the actual anti-diagonal.
|
||||
wrong_approach: "Using i + j == n for anti-diagonal"
|
||||
correct_approach: "Use i + j == n - 1 for anti-diagonal"
|
||||
|
||||
- title: Checking Only One Condition
|
||||
description: |
|
||||
Both conditions must be checked for every cell:
|
||||
- Diagonal cells must be **non-zero**
|
||||
- Non-diagonal cells must be **zero**
|
||||
|
||||
Some solutions only verify that diagonals are non-zero, forgetting to check that everything else is zero, or vice versa.
|
||||
wrong_approach: "Only checking diagonals are non-zero, ignoring other cells"
|
||||
correct_approach: "Verify both conditions: diagonals non-zero AND others zero"
|
||||
|
||||
key_takeaways:
|
||||
- "**Diagonal identification**: In an `n x n` matrix, primary diagonal positions satisfy `i == j`, anti-diagonal positions satisfy `i + j == n - 1`"
|
||||
- "**Early exit optimisation**: Return `false` immediately upon finding any violation rather than checking all cells first"
|
||||
- "**Matrix traversal pattern**: Nested loops with `O(n^2)` complexity are acceptable when you must inspect every cell"
|
||||
- "**Simple validation problems**: When verifying properties, translate the definition directly into conditional checks"
|
||||
|
||||
time_complexity: "O(n^2). We visit each of the n × n cells exactly once to verify its value."
|
||||
space_complexity: "O(1). We only use a constant amount of extra space for loop variables and comparisons."
|
||||
|
||||
solutions:
|
||||
- approach_name: Single Pass Validation
|
||||
is_optimal: true
|
||||
code: |
|
||||
def check_x_matrix(grid: list[list[int]]) -> bool:
|
||||
n = len(grid)
|
||||
|
||||
for i in range(n):
|
||||
for j in range(n):
|
||||
# Check if this position is on either diagonal
|
||||
on_diagonal = (i == j) or (i + j == n - 1)
|
||||
|
||||
if on_diagonal:
|
||||
# Diagonal elements must be non-zero
|
||||
if grid[i][j] == 0:
|
||||
return False
|
||||
else:
|
||||
# Non-diagonal elements must be zero
|
||||
if grid[i][j] != 0:
|
||||
return False
|
||||
|
||||
# All checks passed
|
||||
return True
|
||||
explanation: |
|
||||
**Time Complexity:** O(n^2) — We iterate through all n × n cells once.
|
||||
|
||||
**Space Complexity:** O(1) — Only a few variables used regardless of input size.
|
||||
|
||||
We check each cell exactly once. For diagonal positions (where `i == j` or `i + j == n - 1`), we verify the value is non-zero. For all other positions, we verify the value is zero. Any violation causes an immediate return of `false`.
|
||||
|
||||
- approach_name: Condensed Boolean Check
|
||||
is_optimal: true
|
||||
code: |
|
||||
def check_x_matrix(grid: list[list[int]]) -> bool:
|
||||
n = len(grid)
|
||||
|
||||
for i in range(n):
|
||||
for j in range(n):
|
||||
on_diagonal = (i == j) or (i + j == n - 1)
|
||||
# XOR-like logic: diagonal and non-zero, or not diagonal and zero
|
||||
if on_diagonal != (grid[i][j] != 0):
|
||||
return False
|
||||
|
||||
return True
|
||||
explanation: |
|
||||
**Time Complexity:** O(n^2) — Same traversal as the first approach.
|
||||
|
||||
**Space Complexity:** O(1) — Constant extra space.
|
||||
|
||||
This condensed version uses a clever boolean comparison. The condition `on_diagonal != (grid[i][j] != 0)` returns `True` (invalid) when:
|
||||
- The cell is on a diagonal but has value 0, OR
|
||||
- The cell is not on a diagonal but has a non-zero value
|
||||
|
||||
Both cases represent violations of the X-Matrix property.
|
||||
195
backend/data/questions/check-if-move-is-legal.yaml
Normal file
195
backend/data/questions/check-if-move-is-legal.yaml
Normal file
@@ -0,0 +1,195 @@
|
||||
title: Check if Move is Legal
|
||||
slug: check-if-move-is-legal
|
||||
difficulty: medium
|
||||
leetcode_id: 1958
|
||||
leetcode_url: https://leetcode.com/problems/check-if-move-is-legal/
|
||||
categories:
|
||||
- arrays
|
||||
patterns:
|
||||
- matrix-traversal
|
||||
|
||||
description: |
|
||||
You are given a **0-indexed** `8 x 8` grid `board`, where `board[r][c]` represents the cell `(r, c)` on a game board. On the board, free cells are represented by `'.'`, white cells are represented by `'W'`, and black cells are represented by `'B'`.
|
||||
|
||||
Each move in this game consists of choosing a free cell and changing it to the color you are playing as (either white or black). However, a move is only **legal** if, after changing it, the cell becomes the **endpoint of a good line** (horizontal, vertical, or diagonal).
|
||||
|
||||
A **good line** is a line of **three or more cells (including the endpoints)** where the endpoints of the line are **one color**, and the remaining cells in the middle are the **opposite color** (no cells in the line are free).
|
||||
|
||||
Given two integers `rMove` and `cMove` and a character `color` representing the color you are playing as (white or black), return `true` *if changing cell* `(rMove, cMove)` *to color* `color` *is a legal move, or* `false` *if it is not legal*.
|
||||
|
||||
constraints: |
|
||||
- `board.length == board[r].length == 8`
|
||||
- `0 <= rMove, cMove < 8`
|
||||
- `board[rMove][cMove] == '.'`
|
||||
- `color` is either `'B'` or `'W'`
|
||||
|
||||
examples:
|
||||
- input: 'board = [[".",".",".","B",".",".",".","."],[".",".",".",".W",".",".",".","."],[".",".",".","W",".",".",".","."],[".",".",".","W",".",".",".","."],["W","B","B",".","W","W","W","B"],[".",".",".","B",".",".",".","."],[".",".",".","B",".",".",".","."],[".",".",".","W",".",".",".","."]], rMove = 4, cMove = 3, color = "B"'
|
||||
output: "true"
|
||||
explanation: "Placing 'B' at (4, 3) creates two good lines: a vertical line from (0, 3) to (4, 3) with 'B' endpoints and 'W' in the middle, and a horizontal line from (4, 0) to (4, 3) with endpoints at different colors satisfying the good line condition."
|
||||
- input: 'board = [[".",".",".",".",".",".",".","."],[".",".B",".",".","W",".",".","."],[".",".",".W",".",".",".",".","."],[".",".",".",".W","B",".",".","."],[".",".",".",".",".",".",".","."],[".",".",".",".","B","W",".","."],[".",".",".",".",".",".","W","."],[".",".",".",".",".",".",".","B"]], rMove = 4, cMove = 4, color = "W"'
|
||||
output: "false"
|
||||
explanation: "While there are good lines with the chosen cell as a middle cell (on the diagonal), there are no good lines with the chosen cell as an endpoint. A good line requires the placed cell to be one of the two endpoints."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Think of this problem like the classic board game **Othello (Reversi)**. When you place a piece, you're looking for lines where your color "sandwiches" the opponent's pieces.
|
||||
|
||||
Imagine standing at the cell where you want to place your piece and looking outward in all 8 directions (up, down, left, right, and the four diagonals). For each direction, you're asking: "If I walk this way, do I first see a sequence of opponent pieces, and then eventually see one of my own pieces?"
|
||||
|
||||
The key insight is that a "good line" has a very specific structure:
|
||||
- It starts with your color (the cell you're placing)
|
||||
- It has one or more cells of the opposite color in the middle
|
||||
- It ends with your color (an existing piece on the board)
|
||||
|
||||
The line must be at least 3 cells long (your placement + at least one opponent piece + your existing piece). This means we need to explore each direction and verify this pattern exists.
|
||||
|
||||
approach: |
|
||||
We use **directional exploration** from the target cell:
|
||||
|
||||
**Step 1: Define the 8 directions**
|
||||
|
||||
- Use direction vectors: `(-1, 0)`, `(1, 0)`, `(0, -1)`, `(0, 1)` for cardinal directions
|
||||
- Use `(-1, -1)`, `(-1, 1)`, `(1, -1)`, `(1, 1)` for diagonals
|
||||
- Each vector `(dr, dc)` tells us how to step in that direction
|
||||
|
||||
|
||||
|
||||
**Step 2: For each direction, check for a good line**
|
||||
|
||||
- Start from `(rMove, cMove)` and move in the direction `(dr, dc)`
|
||||
- First, we must encounter at least one cell of the **opposite color**
|
||||
- Then, we must eventually find a cell of **our color**
|
||||
- If we hit the board boundary or an empty cell `'.'` before finding our color, this direction fails
|
||||
|
||||
|
||||
|
||||
**Step 3: Count cells and validate**
|
||||
|
||||
- Track the length of the line as we traverse
|
||||
- A valid good line needs: our placed piece + at least 1 opposite color + our color = minimum 3 cells
|
||||
- If we find at least one opposite color cell followed by our color, we have a good line
|
||||
|
||||
|
||||
|
||||
**Step 4: Return result**
|
||||
|
||||
- If any of the 8 directions produces a valid good line, return `true`
|
||||
- If none do, return `false`
|
||||
|
||||
common_pitfalls:
|
||||
- title: Forgetting the Minimum Length Requirement
|
||||
description: |
|
||||
A good line must have **at least 3 cells**. Some implementations forget this and return `true` when they find just the opposite color without confirming there's at least one opponent piece in between.
|
||||
|
||||
For example, if your piece is adjacent to another piece of your color with no opponent pieces in between, that's NOT a good line — it's just two adjacent same-colored pieces.
|
||||
wrong_approach: "Return true as soon as you find your color in any direction"
|
||||
correct_approach: "Ensure at least one opposite-color cell exists between the endpoints"
|
||||
|
||||
- title: Not Checking All 8 Directions
|
||||
description: |
|
||||
Good lines can be horizontal, vertical, or diagonal. Missing any of the 8 directions means you might return `false` when a valid line exists in an unchecked direction.
|
||||
|
||||
The diagonal directions are often forgotten: `(-1, -1)`, `(-1, 1)`, `(1, -1)`, `(1, 1)`.
|
||||
wrong_approach: "Only check horizontal and vertical (4 directions)"
|
||||
correct_approach: "Check all 8 directions including diagonals"
|
||||
|
||||
- title: Incorrect Boundary Handling
|
||||
description: |
|
||||
When exploring a direction, you might step outside the 8x8 grid. Accessing `board[-1][c]` or `board[r][8]` will cause index errors or incorrect results.
|
||||
|
||||
Always validate that `0 <= r < 8` and `0 <= c < 8` before accessing the board.
|
||||
wrong_approach: "Access board cells without bounds checking"
|
||||
correct_approach: "Check bounds before each cell access"
|
||||
|
||||
- title: Stopping at Empty Cells
|
||||
description: |
|
||||
If you encounter an empty cell `'.'` while searching for the endpoint, the line is broken. A good line cannot have any free cells — only the two endpoint colors and the opposite color in between.
|
||||
|
||||
For example: `B . W B` is NOT a good line because of the empty cell.
|
||||
wrong_approach: "Skip over empty cells while searching"
|
||||
correct_approach: "Return false for this direction when hitting an empty cell"
|
||||
|
||||
key_takeaways:
|
||||
- "**Direction vectors simplify grid traversal**: Using `(dr, dc)` pairs lets you handle all 8 directions with the same code logic"
|
||||
- "**Othello/Reversi pattern**: This problem tests your ability to validate sandwich patterns — a common theme in board game problems"
|
||||
- "**Early termination**: Once you find one valid good line, you can return `true` immediately without checking remaining directions"
|
||||
- "**Boundary awareness**: Grid problems require careful bounds checking to avoid index errors"
|
||||
|
||||
time_complexity: "O(1). The board is always 8x8, and we check at most 8 directions with at most 7 steps each. This is constant time regardless of input."
|
||||
space_complexity: "O(1). We only use a few variables for direction vectors and loop counters. No additional data structures are needed."
|
||||
|
||||
solutions:
|
||||
- approach_name: Direction Exploration
|
||||
is_optimal: true
|
||||
code: |
|
||||
def check_move(board: list[list[str]], rMove: int, cMove: int, color: str) -> bool:
|
||||
# All 8 directions: up, down, left, right, and 4 diagonals
|
||||
directions = [(-1, 0), (1, 0), (0, -1), (0, 1),
|
||||
(-1, -1), (-1, 1), (1, -1), (1, 1)]
|
||||
|
||||
# Determine the opposite color
|
||||
opposite = 'W' if color == 'B' else 'B'
|
||||
|
||||
# Check each direction for a valid good line
|
||||
for dr, dc in directions:
|
||||
# Start one step away from the placed cell
|
||||
r, c = rMove + dr, cMove + dc
|
||||
count = 0 # Count of opposite-color cells
|
||||
|
||||
# Walk in this direction while we see opposite color
|
||||
while 0 <= r < 8 and 0 <= c < 8 and board[r][c] == opposite:
|
||||
count += 1
|
||||
r += dr
|
||||
c += dc
|
||||
|
||||
# After the loop, check if we ended on our color
|
||||
# and had at least one opposite cell in between
|
||||
if count >= 1 and 0 <= r < 8 and 0 <= c < 8 and board[r][c] == color:
|
||||
return True
|
||||
|
||||
return False
|
||||
explanation: |
|
||||
**Time Complexity:** O(1) — The board is fixed at 8x8, so we check at most 8 directions with at most 7 cells each.
|
||||
|
||||
**Space Complexity:** O(1) — Only a few variables for iteration, no extra data structures.
|
||||
|
||||
We systematically check each of the 8 directions from the target cell. For each direction, we count consecutive opposite-color cells and verify the line terminates with our color. The moment we find a valid good line, we return `true`.
|
||||
|
||||
- approach_name: Helper Function per Direction
|
||||
is_optimal: true
|
||||
code: |
|
||||
def check_move(board: list[list[str]], rMove: int, cMove: int, color: str) -> bool:
|
||||
def is_good_line(dr: int, dc: int) -> bool:
|
||||
"""Check if there's a good line in direction (dr, dc)."""
|
||||
opposite = 'W' if color == 'B' else 'B'
|
||||
r, c = rMove + dr, cMove + dc
|
||||
length = 1 # Start counting from placed cell
|
||||
|
||||
# Skip over opposite-color cells
|
||||
while 0 <= r < 8 and 0 <= c < 8 and board[r][c] == opposite:
|
||||
length += 1
|
||||
r += dr
|
||||
c += dc
|
||||
|
||||
# Valid if: we moved at least once, in bounds, and ends with our color
|
||||
# length >= 3 means: our cell + at least 1 opposite + their cell
|
||||
if length >= 3 and 0 <= r < 8 and 0 <= c < 8 and board[r][c] == color:
|
||||
return True
|
||||
return False
|
||||
|
||||
# Check all 8 directions
|
||||
for dr in [-1, 0, 1]:
|
||||
for dc in [-1, 0, 1]:
|
||||
if dr == 0 and dc == 0:
|
||||
continue # Skip the "no movement" case
|
||||
if is_good_line(dr, dc):
|
||||
return True
|
||||
|
||||
return False
|
||||
explanation: |
|
||||
**Time Complexity:** O(1) — Same as above, fixed board size.
|
||||
|
||||
**Space Complexity:** O(1) — The helper function uses only local variables.
|
||||
|
||||
This approach extracts the direction-checking logic into a helper function, making the code more readable. We generate all 8 directions using nested loops over `[-1, 0, 1]` and skip the `(0, 0)` case. The helper function encapsulates the line validation logic cleanly.
|
||||
187
backend/data/questions/check-if-n-and-its-double-exist.yaml
Normal file
187
backend/data/questions/check-if-n-and-its-double-exist.yaml
Normal file
@@ -0,0 +1,187 @@
|
||||
title: Check If N and Its Double Exist
|
||||
slug: check-if-n-and-its-double-exist
|
||||
difficulty: easy
|
||||
leetcode_id: 1346
|
||||
leetcode_url: https://leetcode.com/problems/check-if-n-and-its-double-exist/
|
||||
categories:
|
||||
- arrays
|
||||
- hash-tables
|
||||
patterns:
|
||||
- two-pointers
|
||||
|
||||
description: |
|
||||
Given an array `arr` of integers, check if there exist two indices `i` and `j` such that:
|
||||
|
||||
- `i != j`
|
||||
- `0 <= i, j < arr.length`
|
||||
- `arr[i] == 2 * arr[j]`
|
||||
|
||||
Return `true` if such indices exist, otherwise return `false`.
|
||||
|
||||
constraints: |
|
||||
- `2 <= arr.length <= 500`
|
||||
- `-10^3 <= arr[i] <= 10^3`
|
||||
|
||||
examples:
|
||||
- input: "arr = [10,2,5,3]"
|
||||
output: "true"
|
||||
explanation: "For i = 0 and j = 2, arr[i] == 10 == 2 * 5 == 2 * arr[j]."
|
||||
- input: "arr = [3,1,7,11]"
|
||||
output: "false"
|
||||
explanation: "There is no i and j that satisfy the conditions."
|
||||
- input: "arr = [7,1,14,11]"
|
||||
output: "true"
|
||||
explanation: "For i = 2 and j = 0, arr[i] == 14 == 2 * 7 == 2 * arr[j]."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Think of this problem as a **search problem with a twist**. For each number in the array, we're looking for a specific companion: either its double (twice its value) or its half (if the number is even).
|
||||
|
||||
Imagine you're at a party checking name tags. For each person you meet, you're looking for someone whose name tag shows exactly twice the number on yours, or someone whose number is exactly half of yours. You need to find just one such matching pair.
|
||||
|
||||
The key insight is that we can use a **hash set as a "memory"** of numbers we've already seen. As we iterate through the array, for each number `n`, we check: "Have I already seen `2*n`?" or "Have I already seen `n/2`?". If yes, we've found our pair. If not, we add the current number to our set and continue.
|
||||
|
||||
This "remember what you've seen" pattern is extremely common and allows us to avoid checking every pair explicitly.
|
||||
|
||||
approach: |
|
||||
We solve this using a **Hash Set Approach**:
|
||||
|
||||
**Step 1: Create an empty hash set**
|
||||
|
||||
- `seen`: A set to store numbers we've encountered so far
|
||||
|
||||
|
||||
|
||||
**Step 2: Iterate through the array**
|
||||
|
||||
- For each number `n` in the array:
|
||||
- Check if `2 * n` exists in `seen` — this means we've already seen a number that is half of `n`'s double
|
||||
- Check if `n` is even AND `n // 2` exists in `seen` — this means we've already seen a number that `n` is double of
|
||||
- If either condition is true, return `true` immediately
|
||||
- Otherwise, add `n` to `seen` and continue
|
||||
|
||||
|
||||
|
||||
**Step 3: Return the result**
|
||||
|
||||
- If we finish iterating without finding a pair, return `false`
|
||||
|
||||
|
||||
|
||||
The hash set gives us O(1) lookup time, making the overall solution efficient. Note that we must check if `n` is even before checking `n // 2` to handle odd numbers correctly (since an odd number divided by 2 wouldn't give us a valid integer match).
|
||||
|
||||
common_pitfalls:
|
||||
- title: Forgetting the Zero Edge Case
|
||||
description: |
|
||||
Zero is a special case because `0 == 2 * 0`. If the array contains multiple zeros, the answer should be `true`.
|
||||
|
||||
For example, with `arr = [0, 0]`, we have `arr[0] == 2 * arr[1]` since `0 == 2 * 0`.
|
||||
|
||||
The hash set approach handles this naturally: when we see the second `0`, we check if `2 * 0 = 0` is in the set, and it is (we added the first `0`).
|
||||
wrong_approach: "Not considering that zero satisfies the condition with itself"
|
||||
correct_approach: "Hash set naturally handles this since 0 is added before the second 0 is checked"
|
||||
|
||||
- title: Integer Division for Odd Numbers
|
||||
description: |
|
||||
When checking if `n / 2` exists in the set, we must ensure `n` is even first. For odd numbers like `7`, checking `7 // 2 = 3` would give a false positive if `3` happens to be in the set.
|
||||
|
||||
For example, with `arr = [3, 7]`, we don't want to match `7` with `3` just because `7 // 2 = 3`. We need `14` to match with `7`, not `3`.
|
||||
wrong_approach: "Checking n // 2 without verifying n is even"
|
||||
correct_approach: "Only check n // 2 when n % 2 == 0"
|
||||
|
||||
- title: Using the Same Element Twice
|
||||
description: |
|
||||
The condition requires `i != j`, meaning we can't use the same element as both the number and its double. This is why we check if the double/half exists in `seen` (elements we've **already** passed) rather than checking the current element against itself.
|
||||
|
||||
The iteration order naturally prevents this: we only check against previously seen elements.
|
||||
wrong_approach: "Checking if n == 2 * n (which is only true for n = 0)"
|
||||
correct_approach: "Check against previously seen elements stored in the set"
|
||||
|
||||
key_takeaways:
|
||||
- "**Hash Set for O(1) Lookup**: When searching for specific values or complements, a hash set transforms O(n) searches into O(1) lookups"
|
||||
- "**Two-Way Relationship**: Since `a == 2 * b` implies `b == a / 2`, we can check both directions as we iterate"
|
||||
- "**Remember What You've Seen**: This pattern of maintaining a set of seen values is foundational for many array problems (Two Sum, finding duplicates, etc.)"
|
||||
- "**Edge Cases Matter**: Zero and negative numbers can create subtle bugs — always consider how your conditions behave with boundary values"
|
||||
|
||||
time_complexity: "O(n). We iterate through the array once, and each hash set operation (lookup and insert) takes O(1) average time."
|
||||
space_complexity: "O(n). In the worst case, we store all `n` elements in the hash set before finding a match (or not finding one)."
|
||||
|
||||
solutions:
|
||||
- approach_name: Hash Set
|
||||
is_optimal: true
|
||||
code: |
|
||||
def check_if_exist(arr: list[int]) -> bool:
|
||||
# Set to remember numbers we've seen
|
||||
seen = set()
|
||||
|
||||
for n in arr:
|
||||
# Check if double of n was seen before
|
||||
if 2 * n in seen:
|
||||
return True
|
||||
# Check if half of n was seen (only if n is even)
|
||||
if n % 2 == 0 and n // 2 in seen:
|
||||
return True
|
||||
# Add current number to our memory
|
||||
seen.add(n)
|
||||
|
||||
# No valid pair found
|
||||
return False
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Single pass through the array with O(1) set operations.
|
||||
|
||||
**Space Complexity:** O(n) — Hash set stores up to n elements.
|
||||
|
||||
For each element, we check if its double or half (when even) has been seen before. The hash set provides constant-time lookups, making this approach efficient.
|
||||
|
||||
- approach_name: Brute Force
|
||||
is_optimal: false
|
||||
code: |
|
||||
def check_if_exist(arr: list[int]) -> bool:
|
||||
n = len(arr)
|
||||
|
||||
# Check every pair of elements
|
||||
for i in range(n):
|
||||
for j in range(n):
|
||||
# Skip same index
|
||||
if i == j:
|
||||
continue
|
||||
# Check if arr[i] is double of arr[j]
|
||||
if arr[i] == 2 * arr[j]:
|
||||
return True
|
||||
|
||||
return False
|
||||
explanation: |
|
||||
**Time Complexity:** O(n^2) — Nested loops check all pairs.
|
||||
|
||||
**Space Complexity:** O(1) — No extra space used.
|
||||
|
||||
This approach explicitly checks every pair of indices. While correct and easy to understand, it's less efficient than the hash set approach. For the given constraints (`n <= 500`), this would still pass, but the hash set solution is preferred for its scalability.
|
||||
|
||||
- approach_name: Sorting with Binary Search
|
||||
is_optimal: false
|
||||
code: |
|
||||
def check_if_exist(arr: list[int]) -> bool:
|
||||
arr.sort()
|
||||
n = len(arr)
|
||||
|
||||
for i in range(n):
|
||||
target = 2 * arr[i]
|
||||
# Binary search for the target
|
||||
left, right = 0, n - 1
|
||||
|
||||
while left <= right:
|
||||
mid = (left + right) // 2
|
||||
if arr[mid] == target and mid != i:
|
||||
return True
|
||||
elif arr[mid] < target:
|
||||
left = mid + 1
|
||||
else:
|
||||
right = mid - 1
|
||||
|
||||
return False
|
||||
explanation: |
|
||||
**Time Complexity:** O(n log n) — Sorting takes O(n log n), then n binary searches each take O(log n).
|
||||
|
||||
**Space Complexity:** O(1) or O(n) — Depends on the sorting algorithm used.
|
||||
|
||||
This approach sorts the array first, then uses binary search to find the double of each element. While more efficient than brute force, it's slightly slower than the hash set approach and modifies the original array (or requires extra space to avoid modification).
|
||||
@@ -0,0 +1,172 @@
|
||||
title: Check if Number Has Equal Digit Count and Digit Value
|
||||
slug: check-if-number-has-equal-digit-count-and-digit-value
|
||||
difficulty: easy
|
||||
leetcode_id: 2283
|
||||
leetcode_url: https://leetcode.com/problems/check-if-number-has-equal-digit-count-and-digit-value/
|
||||
categories:
|
||||
- strings
|
||||
- hash-tables
|
||||
patterns:
|
||||
- prefix-sum
|
||||
|
||||
description: |
|
||||
You are given a **0-indexed** string `num` of length `n` consisting of digits.
|
||||
|
||||
Return `true` *if for **every** index* `i` *in the range* `0 <= i < n`*, the digit* `i` *occurs* `num[i]` *times in* `num`*, otherwise return* `false`.
|
||||
|
||||
constraints: |
|
||||
- `n == num.length`
|
||||
- `1 <= n <= 10`
|
||||
- `num` consists only of digits
|
||||
|
||||
examples:
|
||||
- input: 'num = "1210"'
|
||||
output: "true"
|
||||
explanation: |
|
||||
num[0] = '1'. The digit 0 occurs once in num.
|
||||
num[1] = '2'. The digit 1 occurs twice in num.
|
||||
num[2] = '1'. The digit 2 occurs once in num.
|
||||
num[3] = '0'. The digit 3 occurs zero times in num.
|
||||
The condition holds true for every index in "1210", so return true.
|
||||
- input: 'num = "030"'
|
||||
output: "false"
|
||||
explanation: |
|
||||
num[0] = '0'. The digit 0 should occur zero times, but actually occurs twice in num.
|
||||
num[1] = '3'. The digit 1 should occur three times, but actually occurs zero times in num.
|
||||
num[2] = '0'. The digit 2 occurs zero times in num.
|
||||
The indices 0 and 1 both violate the condition, so return false.
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Think of this problem as a **self-describing number** puzzle. Each position in the string tells you how many times that position's index should appear as a digit elsewhere in the string.
|
||||
|
||||
Imagine the string as a set of rules: position 0 says "the digit 0 must appear exactly `num[0]` times", position 1 says "the digit 1 must appear exactly `num[1]` times", and so on. Your job is to verify that the string follows its own rules.
|
||||
|
||||
The key insight is that we need to **count the occurrences** of each digit first, then check if each position's value matches the count for its corresponding index. This is a classic counting problem where we build a frequency map and then validate against it.
|
||||
|
||||
approach: |
|
||||
We solve this using a **Counting Approach**:
|
||||
|
||||
**Step 1: Count digit frequencies**
|
||||
|
||||
- Create a frequency array or hash map to count how many times each digit (0-9) appears in the string
|
||||
- Iterate through the string once to populate these counts
|
||||
|
||||
|
||||
|
||||
**Step 2: Validate the self-describing property**
|
||||
|
||||
- For each index `i` from `0` to `n-1`:
|
||||
- Get the expected count from `num[i]` (convert character to integer)
|
||||
- Get the actual count of digit `i` from our frequency map
|
||||
- If they don't match, return `false`
|
||||
|
||||
|
||||
|
||||
**Step 3: Return the result**
|
||||
|
||||
- If all positions pass validation, return `true`
|
||||
|
||||
|
||||
|
||||
This approach works because we separate the problem into two clear phases: counting and validating. The counting phase gives us the ground truth, and the validation phase checks if the string's claims match reality.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Confusing Index and Value
|
||||
description: |
|
||||
The problem has a subtle twist: at index `i`, the *value* `num[i]` tells us how many times the *digit* `i` should appear.
|
||||
|
||||
For example, at index 2, if `num[2] = '1'`, this means the digit `2` should appear exactly once in the string — not that the digit `1` should appear twice.
|
||||
|
||||
Read carefully: we're checking if digit `i` occurs `num[i]` times.
|
||||
wrong_approach: "Checking if digit num[i] occurs i times"
|
||||
correct_approach: "Checking if digit i occurs num[i] times"
|
||||
|
||||
- title: Character vs Integer Conversion
|
||||
description: |
|
||||
Remember that `num` is a string, so `num[i]` gives a character like `'2'`, not the integer `2`.
|
||||
|
||||
You need to convert: `int(num[i])` in Python, or `num[i] - '0'` in languages like C++ or Java.
|
||||
|
||||
Forgetting this conversion leads to comparing characters with integers, which will give wrong results or type errors.
|
||||
wrong_approach: "Comparing num[i] directly with count"
|
||||
correct_approach: "Convert num[i] to integer before comparison"
|
||||
|
||||
- title: Not Handling All Indices
|
||||
description: |
|
||||
Since the string has length `n`, we only need to check indices `0` through `n-1`. We only care about digits `0` through `n-1` as well, since larger digits can't be valid indices.
|
||||
|
||||
However, digits larger than `n-1` appearing in the string is fine — they just won't be checked as indices. The constraint `n <= 10` ensures all digits 0-9 are potentially valid.
|
||||
|
||||
key_takeaways:
|
||||
- "**Self-describing structures**: Some problems define constraints that reference themselves — break these into counting and validation phases"
|
||||
- "**Frequency counting pattern**: Building a frequency map first simplifies many validation problems"
|
||||
- "**Index vs value awareness**: Pay close attention to what the index represents vs what the value represents"
|
||||
- "**Small constraint optimisation**: With `n <= 10`, even O(n^2) would work, but O(n) is cleaner and teaches better habits"
|
||||
|
||||
time_complexity: "O(n). We iterate through the string twice: once to count frequencies, once to validate."
|
||||
space_complexity: "O(1). We use a fixed-size frequency array of 10 elements (for digits 0-9), regardless of input size."
|
||||
|
||||
solutions:
|
||||
- approach_name: Frequency Counting
|
||||
is_optimal: true
|
||||
code: |
|
||||
def digit_count(num: str) -> bool:
|
||||
# Count occurrences of each digit (0-9)
|
||||
freq = [0] * 10
|
||||
for char in num:
|
||||
freq[int(char)] += 1
|
||||
|
||||
# Check if each index i has digit i appearing num[i] times
|
||||
for i in range(len(num)):
|
||||
expected = int(num[i]) # How many times digit i should appear
|
||||
actual = freq[i] # How many times digit i actually appears
|
||||
if expected != actual:
|
||||
return False
|
||||
|
||||
return True
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Two passes through the string of length n.
|
||||
|
||||
**Space Complexity:** O(1) — Fixed array of 10 integers.
|
||||
|
||||
We first count all digit occurrences, then verify each position's claim. The fixed-size frequency array makes this space-efficient.
|
||||
|
||||
- approach_name: Counter with Pythonic Check
|
||||
is_optimal: true
|
||||
code: |
|
||||
from collections import Counter
|
||||
|
||||
def digit_count(num: str) -> bool:
|
||||
# Count all digit occurrences
|
||||
count = Counter(num)
|
||||
|
||||
# Check each index: digit i should occur num[i] times
|
||||
for i, char in enumerate(num):
|
||||
expected = int(char)
|
||||
actual = count.get(str(i), 0) # Get count of digit i (as string)
|
||||
if expected != actual:
|
||||
return False
|
||||
|
||||
return True
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Counter creation and validation loop.
|
||||
|
||||
**Space Complexity:** O(k) — Where k is the number of unique digits (at most 10).
|
||||
|
||||
This approach uses Python's Counter for cleaner counting. Note that we look up `str(i)` since Counter keys are characters, not integers.
|
||||
|
||||
- approach_name: One-Liner (Pythonic)
|
||||
is_optimal: true
|
||||
code: |
|
||||
from collections import Counter
|
||||
|
||||
def digit_count(num: str) -> bool:
|
||||
count = Counter(num)
|
||||
return all(count.get(str(i), 0) == int(c) for i, c in enumerate(num))
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Single pass with generator expression.
|
||||
|
||||
**Space Complexity:** O(k) — Counter storage for unique digits.
|
||||
|
||||
A concise one-liner using `all()` with a generator. For each position, we check if the count of that index-as-digit matches the expected value. Elegant but less readable for beginners.
|
||||
@@ -0,0 +1,176 @@
|
||||
title: Check if Number is a Sum of Powers of Three
|
||||
slug: check-if-number-is-a-sum-of-powers-of-three
|
||||
difficulty: medium
|
||||
leetcode_id: 1780
|
||||
leetcode_url: https://leetcode.com/problems/check-if-number-is-a-sum-of-powers-of-three/
|
||||
categories:
|
||||
- math
|
||||
patterns:
|
||||
- greedy
|
||||
|
||||
description: |
|
||||
Given an integer `n`, return `true` *if it is possible to represent* `n` *as the sum of distinct powers of three*. Otherwise, return `false`.
|
||||
|
||||
An integer `y` is a power of three if there exists an integer `x` such that `y == 3^x`.
|
||||
|
||||
constraints: |
|
||||
- `1 <= n <= 10^7`
|
||||
|
||||
examples:
|
||||
- input: "n = 12"
|
||||
output: "true"
|
||||
explanation: "12 = 3^1 + 3^2 = 3 + 9"
|
||||
- input: "n = 91"
|
||||
output: "true"
|
||||
explanation: "91 = 3^0 + 3^2 + 3^4 = 1 + 9 + 81"
|
||||
- input: "n = 21"
|
||||
output: "false"
|
||||
explanation: "21 cannot be represented as a sum of distinct powers of three."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Think of this problem like converting a number to a different base system, specifically **base 3 (ternary)**.
|
||||
|
||||
In our familiar decimal system, we use digits 0-9. In binary (base 2), we use 0-1. In ternary (base 3), we use digits 0, 1, and 2. Each position represents a power of 3: the rightmost position is 3^0 = 1, the next is 3^1 = 3, then 3^2 = 9, and so on.
|
||||
|
||||
Here's the key insight: when we represent `n` in base 3, if all digits are either **0 or 1**, then `n` can be written as a sum of distinct powers of three. Why? Because a digit of 1 in position `i` means we include 3^i in our sum, and a digit of 0 means we don't. Each power of three is used at most once.
|
||||
|
||||
However, if any digit is **2**, that means we need to use some power of three *twice* in our sum, which violates the "distinct" requirement. For example, 21 in base 3 is `210` (2×9 + 1×3 + 0×1), meaning we'd need to use 3^2 = 9 twice — not allowed!
|
||||
|
||||
So the problem reduces to: **convert `n` to base 3 and check if all digits are 0 or 1**.
|
||||
|
||||
approach: |
|
||||
We solve this using **Ternary (Base-3) Digit Checking**:
|
||||
|
||||
**Step 1: Repeatedly extract the last digit in base 3**
|
||||
|
||||
- Use `n % 3` to get the rightmost digit in base 3
|
||||
- If this digit is 2, immediately return `false`
|
||||
- A digit of 2 means we'd need to use that power of 3 twice
|
||||
|
||||
|
||||
|
||||
**Step 2: Remove the last digit**
|
||||
|
||||
- Use integer division `n //= 3` to remove the digit we just checked
|
||||
- This shifts all remaining digits one position to the right
|
||||
|
||||
|
||||
|
||||
**Step 3: Repeat until n becomes 0**
|
||||
|
||||
- Continue the loop while `n > 0`
|
||||
- If we check all digits without finding a 2, return `true`
|
||||
|
||||
|
||||
|
||||
This is essentially converting `n` to base 3 and validating each digit as we go, without storing the full representation.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Trying All Combinations of Powers
|
||||
description: |
|
||||
A brute force approach might try to enumerate all possible subsets of powers of 3 up to `n` and check if any subset sums to `n`.
|
||||
|
||||
With `n <= 10^7`, we have up to 15 powers of 3 (since 3^15 > 10^7). Checking all 2^15 = 32,768 subsets is technically feasible but unnecessary and much slower than the O(log n) ternary approach.
|
||||
wrong_approach: "Enumerate all subsets of powers of 3"
|
||||
correct_approach: "Check base-3 representation for digit 2"
|
||||
|
||||
- title: Forgetting Why Digit 2 Fails
|
||||
description: |
|
||||
It's important to understand *why* a digit of 2 means the answer is false. If the base-3 representation has a 2 in position `i`, that means `n` contains `2 × 3^i` as part of its decomposition.
|
||||
|
||||
Since we can only use each power of 3 at most once (distinct), we cannot represent `2 × 3^i` as a sum of distinct powers. For instance, `2 × 9 = 18` cannot be written as a sum of distinct powers of 3 that equals 18 — we'd need 9 + 9, but that uses 9 twice.
|
||||
wrong_approach: "Not understanding the connection to base-3"
|
||||
correct_approach: "Recognise that ternary digits > 1 require repeated powers"
|
||||
|
||||
- title: Integer Overflow with Large Powers
|
||||
description: |
|
||||
If you precompute all powers of 3 up to `n`, be careful not to overflow. In Python this isn't an issue, but in languages like C++ or Java, 3^20 exceeds 32-bit integer limits.
|
||||
|
||||
The iterative modulo approach avoids this entirely since we only work with values up to `n`.
|
||||
wrong_approach: "Precomputing large powers without overflow checks"
|
||||
correct_approach: "Use iterative n % 3 and n // 3 approach"
|
||||
|
||||
key_takeaways:
|
||||
- "**Base conversion insight**: Many problems involving sums of distinct powers can be solved by examining the number in that base"
|
||||
- "**Ternary representation**: A number is a sum of distinct powers of 3 if and only if its base-3 representation contains only 0s and 1s"
|
||||
- "**Pattern recognition**: This same logic applies to powers of any base — e.g., all positive integers are sums of distinct powers of 2 (binary has only 0s and 1s by definition)"
|
||||
- "**Efficiency through number theory**: Converting a brute force subset problem into a simple digit-checking problem reduces complexity dramatically"
|
||||
|
||||
time_complexity: "O(log n). We divide `n` by 3 in each iteration, so we process at most log_3(n) digits."
|
||||
space_complexity: "O(1). We only use a single variable to track `n` as we modify it."
|
||||
|
||||
solutions:
|
||||
- approach_name: Ternary Digit Check
|
||||
is_optimal: true
|
||||
code: |
|
||||
def check_powers_of_three(n: int) -> bool:
|
||||
# Check each digit in base-3 representation
|
||||
while n > 0:
|
||||
# Get the rightmost digit in base 3
|
||||
if n % 3 == 2:
|
||||
# Digit 2 means we'd need to use a power twice
|
||||
return False
|
||||
# Remove the digit we just checked
|
||||
n //= 3
|
||||
|
||||
# All digits were 0 or 1
|
||||
return True
|
||||
explanation: |
|
||||
**Time Complexity:** O(log n) — We divide by 3 each iteration, processing log_3(n) digits.
|
||||
|
||||
**Space Complexity:** O(1) — Only uses the input variable.
|
||||
|
||||
We convert `n` to base 3 on the fly, checking each digit. If any digit equals 2, we need that power of 3 twice, which violates the distinct requirement. If all digits are 0 or 1, the sum is valid.
|
||||
|
||||
- approach_name: Greedy Subtraction
|
||||
is_optimal: false
|
||||
code: |
|
||||
def check_powers_of_three(n: int) -> bool:
|
||||
# Find the largest power of 3 <= n
|
||||
power = 1
|
||||
while power * 3 <= n:
|
||||
power *= 3
|
||||
|
||||
# Greedily subtract powers of 3
|
||||
while n > 0 and power > 0:
|
||||
if power <= n:
|
||||
n -= power
|
||||
power //= 3
|
||||
|
||||
return n == 0
|
||||
explanation: |
|
||||
**Time Complexity:** O(log n) — Two passes through powers of 3.
|
||||
|
||||
**Space Complexity:** O(1) — Only tracking `power` and `n`.
|
||||
|
||||
We greedily subtract the largest possible power of 3 at each step, using each power at most once. If we reduce `n` to 0, the answer is true. This is equivalent to the ternary approach but less elegant — it's harder to see why it works.
|
||||
|
||||
- approach_name: Subset Sum with Powers
|
||||
is_optimal: false
|
||||
code: |
|
||||
def check_powers_of_three(n: int) -> bool:
|
||||
# Generate all powers of 3 up to n
|
||||
powers = []
|
||||
power = 1
|
||||
while power <= n:
|
||||
powers.append(power)
|
||||
power *= 3
|
||||
|
||||
# Use recursion to check if any subset sums to n
|
||||
def can_sum(index: int, remaining: int) -> bool:
|
||||
if remaining == 0:
|
||||
return True
|
||||
if remaining < 0 or index == len(powers):
|
||||
return False
|
||||
# Include or exclude current power
|
||||
return (can_sum(index + 1, remaining - powers[index]) or
|
||||
can_sum(index + 1, remaining))
|
||||
|
||||
return can_sum(0, n)
|
||||
explanation: |
|
||||
**Time Complexity:** O(2^k) where k = log_3(n) — potentially checking all subsets.
|
||||
|
||||
**Space Complexity:** O(k) — recursion depth and powers array.
|
||||
|
||||
This brute force approach tries all combinations of powers of 3. While it works for the given constraints (k <= 15), it's much slower than the O(log n) ternary approach. Included to show the connection to subset sum problems.
|
||||
@@ -0,0 +1,183 @@
|
||||
title: Check if Numbers Are Ascending in a Sentence
|
||||
slug: check-if-numbers-are-ascending-in-a-sentence
|
||||
difficulty: easy
|
||||
leetcode_id: 2042
|
||||
leetcode_url: https://leetcode.com/problems/check-if-numbers-are-ascending-in-a-sentence/
|
||||
categories:
|
||||
- strings
|
||||
patterns:
|
||||
- two-pointers
|
||||
|
||||
description: |
|
||||
A sentence is a list of **tokens** separated by a **single** space with no leading or trailing spaces. Every token is either a **positive number** consisting of digits `0-9` with no leading zeros, or a **word** consisting of lowercase English letters.
|
||||
|
||||
For example, `"a puppy has 2 eyes 4 legs"` is a sentence with seven tokens: `"2"` and `"4"` are numbers and the other tokens such as `"puppy"` are words.
|
||||
|
||||
Given a string `s` representing a sentence, you need to check if **all** the numbers in `s` are **strictly increasing** from left to right (i.e., other than the last number, **each** number is **strictly smaller** than the number on its **right** in `s`).
|
||||
|
||||
Return `true` if so, or `false` otherwise.
|
||||
|
||||
constraints: |
|
||||
- `3 <= s.length <= 200`
|
||||
- `s` consists of lowercase English letters, spaces, and digits from `0` to `9`, inclusive
|
||||
- The number of tokens in `s` is between `2` and `100`, inclusive
|
||||
- The tokens in `s` are separated by a single space
|
||||
- There are at least **two** numbers in `s`
|
||||
- Each number in `s` is a **positive** number **less** than `100`, with no leading zeros
|
||||
- `s` contains no leading or trailing spaces
|
||||
|
||||
examples:
|
||||
- input: 's = "1 box has 3 blue 4 red 6 green and 12 yellow marbles"'
|
||||
output: "true"
|
||||
explanation: "The numbers in s are: 1, 3, 4, 6, 12. They are strictly increasing from left to right: 1 < 3 < 4 < 6 < 12."
|
||||
- input: 's = "hello world 5 x 5"'
|
||||
output: "false"
|
||||
explanation: "The numbers in s are: 5, 5. They are not strictly increasing."
|
||||
- input: 's = "sunset is at 7 51 pm overnight lows will be in the low 50 and 60 s"'
|
||||
output: "false"
|
||||
explanation: "The numbers in s are: 7, 51, 50, 60. They are not strictly increasing."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Imagine reading a sentence aloud and keeping mental track of any numbers you encounter. Each time you see a new number, you compare it to the last number you remember. If it's bigger, you update your memory with this new number and continue. If it's smaller or equal, you know the sequence isn't strictly increasing.
|
||||
|
||||
The key insight is that we don't need to store all the numbers — we only need to remember the **most recent number** we've seen. This is because "strictly increasing" is a *transitive* property: if each number is greater than the one before it, then all numbers form an increasing sequence.
|
||||
|
||||
Think of it like climbing stairs: you only need to check that each step is higher than the one you're currently on, not every step you've already climbed.
|
||||
|
||||
approach: |
|
||||
We solve this using a **Single Pass with Token Extraction**:
|
||||
|
||||
**Step 1: Split the sentence into tokens**
|
||||
|
||||
- Use the space character as a delimiter to split the sentence
|
||||
- This gives us an array of words and numbers as strings
|
||||
|
||||
|
||||
|
||||
**Step 2: Initialise a tracker for the previous number**
|
||||
|
||||
- `prev_num`: Set to `0` initially (since all numbers are positive, any valid number will be greater)
|
||||
|
||||
|
||||
|
||||
**Step 3: Iterate through each token**
|
||||
|
||||
- For each token, check if it consists entirely of digits (using `isdigit()`)
|
||||
- If it's a number:
|
||||
- Convert the string to an integer
|
||||
- Compare it with `prev_num`
|
||||
- If `current_num <= prev_num`, return `false` immediately
|
||||
- Otherwise, update `prev_num = current_num` and continue
|
||||
|
||||
|
||||
|
||||
**Step 4: Return the result**
|
||||
|
||||
- If we complete the iteration without finding a violation, return `true`
|
||||
|
||||
common_pitfalls:
|
||||
- title: Checking for Non-Strict Increase
|
||||
description: |
|
||||
The problem requires **strictly increasing** numbers, meaning each number must be **greater than** (not equal to) the previous one.
|
||||
|
||||
With `s = "hello world 5 x 5"`, the numbers are 5 and 5. Since `5 == 5` (not `5 > 5`), this should return `false`.
|
||||
|
||||
Make sure your comparison uses `<=` to catch equal values as violations, not just `<`.
|
||||
wrong_approach: "Only checking if current_num < prev_num"
|
||||
correct_approach: "Checking if current_num <= prev_num"
|
||||
|
||||
- title: String Comparison Instead of Integer Comparison
|
||||
description: |
|
||||
When comparing numbers, you must convert strings to integers first. String comparison uses lexicographic order, which doesn't match numeric order.
|
||||
|
||||
For example, `"9" > "12"` is `true` in string comparison (because '9' comes after '1' alphabetically), but numerically `9 < 12`.
|
||||
|
||||
Always convert to integers before comparing: `int("9") < int("12")` correctly gives `true`.
|
||||
wrong_approach: 'Comparing tokens directly as strings: "9" > "12"'
|
||||
correct_approach: "Converting to int first: int(token)"
|
||||
|
||||
- title: Missing the First or Last Number
|
||||
description: |
|
||||
Off-by-one errors can occur if you don't properly handle the initial state or the iteration bounds.
|
||||
|
||||
Setting `prev_num = 0` works because all valid numbers in this problem are positive (≥ 1). Any valid first number will be greater than 0.
|
||||
|
||||
If the problem allowed 0 as a valid number, you'd need a different sentinel value like `-1` or use a flag to track if you've seen the first number.
|
||||
|
||||
key_takeaways:
|
||||
- "**Single-pass pattern**: Track just what you need (the previous number) rather than storing all numbers"
|
||||
- "**Sentinel values**: Using `0` as `prev_num` works because valid numbers are always positive — this avoids special-casing the first number"
|
||||
- "**String vs numeric comparison**: Always convert strings to integers when comparing numerical values"
|
||||
- "**Strictly vs non-strictly increasing**: Pay close attention to whether `>` or `>=` is required"
|
||||
|
||||
time_complexity: "O(n). We iterate through each character of the string once during splitting and token processing, where `n` is the length of the string."
|
||||
space_complexity: "O(n). The split operation creates an array of tokens that in the worst case contains O(n) characters total."
|
||||
|
||||
solutions:
|
||||
- approach_name: Single Pass with Split
|
||||
is_optimal: true
|
||||
code: |
|
||||
def are_numbers_ascending(s: str) -> bool:
|
||||
# Split sentence into individual tokens
|
||||
tokens = s.split()
|
||||
|
||||
# Track the previous number seen (0 works since all numbers are positive)
|
||||
prev_num = 0
|
||||
|
||||
for token in tokens:
|
||||
# Check if this token is a number (all digits)
|
||||
if token.isdigit():
|
||||
# Convert to integer for proper numeric comparison
|
||||
current_num = int(token)
|
||||
|
||||
# Must be strictly greater than previous number
|
||||
if current_num <= prev_num:
|
||||
return False
|
||||
|
||||
# Update tracker for next comparison
|
||||
prev_num = current_num
|
||||
|
||||
# All numbers were strictly increasing
|
||||
return True
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — We split the string once and iterate through each token.
|
||||
|
||||
**Space Complexity:** O(n) — The split creates a list of tokens.
|
||||
|
||||
This solution leverages Python's `split()` to tokenise the sentence, then checks each token. Using `isdigit()` cleanly identifies numbers, and comparing integers ensures correct numeric ordering.
|
||||
|
||||
- approach_name: Character-by-Character Parsing
|
||||
is_optimal: false
|
||||
code: |
|
||||
def are_numbers_ascending(s: str) -> bool:
|
||||
prev_num = 0
|
||||
i = 0
|
||||
n = len(s)
|
||||
|
||||
while i < n:
|
||||
# Skip non-digit characters
|
||||
if not s[i].isdigit():
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Found start of a number - extract all digits
|
||||
current_num = 0
|
||||
while i < n and s[i].isdigit():
|
||||
# Build number digit by digit
|
||||
current_num = current_num * 10 + int(s[i])
|
||||
i += 1
|
||||
|
||||
# Check if strictly increasing
|
||||
if current_num <= prev_num:
|
||||
return False
|
||||
|
||||
prev_num = current_num
|
||||
|
||||
return True
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Single pass through the string.
|
||||
|
||||
**Space Complexity:** O(1) — No extra data structures, just integer variables.
|
||||
|
||||
This approach manually parses the string character by character, building numbers digit by digit. While it uses constant space (no split array), it's more verbose and error-prone. The split approach is preferred for readability unless memory is extremely constrained.
|
||||
206
backend/data/questions/check-if-object-instance-of-class.yaml
Normal file
206
backend/data/questions/check-if-object-instance-of-class.yaml
Normal file
@@ -0,0 +1,206 @@
|
||||
title: Check if Object Instance of Class
|
||||
slug: check-if-object-instance-of-class
|
||||
difficulty: medium
|
||||
leetcode_id: 2618
|
||||
leetcode_url: https://leetcode.com/problems/check-if-object-instance-of-class/
|
||||
categories:
|
||||
- recursion
|
||||
patterns:
|
||||
- dfs
|
||||
|
||||
description: |
|
||||
Write a function that checks if a given value is an instance of a given class or superclass. For this problem, an object is considered an instance of a given class if that object has access to that class's methods.
|
||||
|
||||
There are no constraints on the data types that can be passed to the function. For example, the value or the class could be `undefined`.
|
||||
|
||||
constraints: |
|
||||
- `value` is any valid JavaScript value
|
||||
- `classFunction` is any valid JavaScript value
|
||||
|
||||
examples:
|
||||
- input: "func = () => checkIfInstanceOf(new Date(), Date)"
|
||||
output: "true"
|
||||
explanation: "The object returned by the Date constructor is, by definition, an instance of Date."
|
||||
- input: "func = () => { class Animal {}; class Dog extends Animal {}; return checkIfInstanceOf(new Dog(), Animal); }"
|
||||
output: "true"
|
||||
explanation: "Dog is a subclass of Animal. Therefore, a Dog object is an instance of both Dog and Animal."
|
||||
- input: "func = () => checkIfInstanceOf(Date, Date)"
|
||||
output: "false"
|
||||
explanation: "A date constructor cannot logically be an instance of itself."
|
||||
- input: "func = () => checkIfInstanceOf(5, Number)"
|
||||
output: "true"
|
||||
explanation: "5 is a Number. Note that the 'instanceof' keyword would return false. However, it is still considered an instance of Number because it accesses the Number methods. For example 'toFixed()'."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Think of JavaScript's prototype chain as a family tree. Every object has a "parent" (its prototype), and that parent may have its own parent, forming a chain all the way up to `null`.
|
||||
|
||||
When we check if an object is an "instance" of a class, we're really asking: **"Is this class's prototype anywhere in the object's ancestry?"**
|
||||
|
||||
The twist in this problem is that JavaScript's native `instanceof` operator doesn't work with primitives like `5` or `"hello"`. These primitives aren't technically objects, yet they *can* access methods from their wrapper types (`Number`, `String`). For example, `(5).toFixed(2)` works because JavaScript temporarily "boxes" the primitive into its object wrapper.
|
||||
|
||||
So our solution needs to handle both objects and primitives. The key insight is that `Object(value)` converts any value to its object form — primitives get wrapped, and objects stay unchanged. Once we have an object, we can walk up its prototype chain looking for our target class.
|
||||
|
||||
approach: |
|
||||
We solve this by **walking the prototype chain** after handling edge cases:
|
||||
|
||||
**Step 1: Handle null and undefined values**
|
||||
|
||||
- If `value` is `null` or `undefined`, return `false` immediately
|
||||
- These values have no prototype chain to traverse
|
||||
|
||||
|
||||
|
||||
**Step 2: Validate the class function**
|
||||
|
||||
- If `classFunction` is not a function, return `false`
|
||||
- Only functions have a `prototype` property we can compare against
|
||||
|
||||
|
||||
|
||||
**Step 3: Convert primitives to objects**
|
||||
|
||||
- Use `Object(value)` to wrap primitives in their object form
|
||||
- This allows `5` to become a `Number` object, `"hi"` to become a `String` object
|
||||
- Objects pass through unchanged
|
||||
|
||||
|
||||
|
||||
**Step 4: Walk the prototype chain**
|
||||
|
||||
- Start with `Object.getPrototypeOf(obj)` to get the object's prototype
|
||||
- Compare each prototype with `classFunction.prototype`
|
||||
- If they match, return `true`
|
||||
- If we reach `null` (end of chain), return `false`
|
||||
|
||||
|
||||
|
||||
This approach correctly handles inheritance: when checking `new Dog()` against `Animal`, we find `Animal.prototype` further up the chain from `Dog.prototype`.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Using Native instanceof
|
||||
description: |
|
||||
The native `instanceof` operator fails for primitives:
|
||||
|
||||
```javascript
|
||||
5 instanceof Number // false
|
||||
"hi" instanceof String // false
|
||||
```
|
||||
|
||||
But per the problem definition, `5` *is* an instance of `Number` because it can access `Number` methods. We must handle primitives by converting them to their object wrappers first.
|
||||
wrong_approach: "return value instanceof classFunction"
|
||||
correct_approach: "Convert primitives with Object(value), then traverse prototype chain"
|
||||
|
||||
- title: Forgetting Edge Cases
|
||||
description: |
|
||||
Both `value` and `classFunction` can be any JavaScript value, including `null`, `undefined`, or non-functions.
|
||||
|
||||
- `checkIfInstanceOf(null, Object)` should return `false` (null has no prototype)
|
||||
- `checkIfInstanceOf({}, undefined)` should return `false` (undefined isn't a class)
|
||||
- `checkIfInstanceOf({}, {})` should return `false` (objects aren't constructor functions)
|
||||
|
||||
Always validate inputs before traversing the prototype chain.
|
||||
wrong_approach: "Assume value is an object and classFunction is a function"
|
||||
correct_approach: "Check for null/undefined and verify classFunction is a function"
|
||||
|
||||
- title: Infinite Loop on Prototype Chain
|
||||
description: |
|
||||
The prototype chain always terminates at `null`. Forgetting to check for this termination condition leads to errors or infinite loops.
|
||||
|
||||
```javascript
|
||||
Object.getPrototypeOf(Object.prototype) // null
|
||||
```
|
||||
|
||||
Your while loop must stop when the prototype becomes `null`.
|
||||
wrong_approach: "while (proto) without null check"
|
||||
correct_approach: "while (proto !== null) with explicit termination"
|
||||
|
||||
key_takeaways:
|
||||
- "**Prototype chain traversal**: JavaScript inheritance works through prototype chains — understanding this is fundamental to JavaScript"
|
||||
- "**Primitives vs objects**: Primitives like `5` and `\"hello\"` can access methods because JavaScript temporarily boxes them, but they fail `instanceof` checks"
|
||||
- "**Object() wrapper**: `Object(value)` is a powerful technique to normalise primitives and objects for uniform handling"
|
||||
- "**Defensive programming**: Always validate inputs in JavaScript — any value can be passed to any function"
|
||||
|
||||
time_complexity: "O(d) where d is the depth of the prototype chain. For most objects, this is a small constant (typically 2-4 levels)."
|
||||
space_complexity: "O(1). We only use a single pointer variable to traverse the chain."
|
||||
|
||||
solutions:
|
||||
- approach_name: Prototype Chain Traversal
|
||||
is_optimal: true
|
||||
language: javascript
|
||||
code: |
|
||||
function checkIfInstanceOf(obj, classFunction) {
|
||||
// Handle null/undefined - they have no prototype chain
|
||||
if (obj === null || obj === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// classFunction must be a function to have a prototype
|
||||
if (typeof classFunction !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Convert primitives to their object wrappers
|
||||
// This allows 5 to become Number, "hi" to become String, etc.
|
||||
obj = Object(obj);
|
||||
|
||||
// Walk up the prototype chain
|
||||
let proto = Object.getPrototypeOf(obj);
|
||||
|
||||
while (proto !== null) {
|
||||
// Found a match in the prototype chain
|
||||
if (proto === classFunction.prototype) {
|
||||
return true;
|
||||
}
|
||||
// Move to the next prototype in the chain
|
||||
proto = Object.getPrototypeOf(proto);
|
||||
}
|
||||
|
||||
// Reached end of chain without finding a match
|
||||
return false;
|
||||
}
|
||||
explanation: |
|
||||
**Time Complexity:** O(d) — We traverse at most d prototypes where d is the chain depth.
|
||||
|
||||
**Space Complexity:** O(1) — Only one pointer variable used.
|
||||
|
||||
We first handle edge cases (null/undefined values, non-function classes), then convert primitives to objects. Finally, we walk up the prototype chain comparing each prototype against our target class's prototype.
|
||||
|
||||
- approach_name: Recursive Prototype Check
|
||||
is_optimal: false
|
||||
language: javascript
|
||||
code: |
|
||||
function checkIfInstanceOf(obj, classFunction) {
|
||||
// Handle null/undefined
|
||||
if (obj === null || obj === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// classFunction must be a function
|
||||
if (typeof classFunction !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Recursive helper to check prototype chain
|
||||
function checkProto(proto) {
|
||||
// Base case: reached end of chain
|
||||
if (proto === null) {
|
||||
return false;
|
||||
}
|
||||
// Found match
|
||||
if (proto === classFunction.prototype) {
|
||||
return true;
|
||||
}
|
||||
// Recurse up the chain
|
||||
return checkProto(Object.getPrototypeOf(proto));
|
||||
}
|
||||
|
||||
// Start from the object's prototype
|
||||
return checkProto(Object.getPrototypeOf(Object(obj)));
|
||||
}
|
||||
explanation: |
|
||||
**Time Complexity:** O(d) — Same traversal depth as iterative approach.
|
||||
|
||||
**Space Complexity:** O(d) — Recursive call stack grows with chain depth.
|
||||
|
||||
This recursive approach is conceptually cleaner but uses extra stack space. The iterative version is preferred for its O(1) space complexity.
|
||||
@@ -0,0 +1,159 @@
|
||||
title: Check if One String Swap Can Make Strings Equal
|
||||
slug: check-if-one-string-swap-can-make-strings-equal
|
||||
difficulty: easy
|
||||
leetcode_id: 1790
|
||||
leetcode_url: https://leetcode.com/problems/check-if-one-string-swap-can-make-strings-equal/
|
||||
categories:
|
||||
- strings
|
||||
- hash-tables
|
||||
patterns:
|
||||
- two-pointers
|
||||
|
||||
description: |
|
||||
You are given two strings `s1` and `s2` of equal length. A **string swap** is an operation where you choose two indices in a string (not necessarily different) and swap the characters at these indices.
|
||||
|
||||
Return `true` *if it is possible to make both strings equal by performing **at most one string swap** on **exactly one** of the strings.* Otherwise, return `false`.
|
||||
|
||||
constraints: |
|
||||
- `1 <= s1.length, s2.length <= 100`
|
||||
- `s1.length == s2.length`
|
||||
- `s1` and `s2` consist of only lowercase English letters.
|
||||
|
||||
examples:
|
||||
- input: 's1 = "bank", s2 = "kanb"'
|
||||
output: "true"
|
||||
explanation: "Swap the first character with the last character of s2 to make \"bank\"."
|
||||
- input: 's1 = "attack", s2 = "defend"'
|
||||
output: "false"
|
||||
explanation: "It is impossible to make them equal with one string swap."
|
||||
- input: 's1 = "kelb", s2 = "kelb"'
|
||||
output: "true"
|
||||
explanation: "The two strings are already equal, so no string swap operation is required."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Think of this problem as finding **mismatches** between the two strings. Since we can only perform at most one swap, there's a strict limit on how different the strings can be.
|
||||
|
||||
Imagine laying both strings side by side and comparing character by character. If they're already identical, we're done — no swap needed. If they differ at exactly two positions, we might be able to fix it with a single swap. But if they differ at more than two positions, no single swap can help.
|
||||
|
||||
The key insight is this: for a single swap to work, the characters at the two mismatched positions must be **cross-matched**. If `s1[i] != s2[i]` and `s1[j] != s2[j]`, then swapping works only if `s1[i] == s2[j]` and `s1[j] == s2[i]`.
|
||||
|
||||
Think of it like having two pairs of socks that got mixed up — you can only fix it if each sock from the first pair matches its counterpart in the second pair.
|
||||
|
||||
approach: |
|
||||
We solve this by finding all positions where the strings differ and checking if a swap can fix them.
|
||||
|
||||
**Step 1: Find all differing positions**
|
||||
|
||||
- Iterate through both strings simultaneously
|
||||
- Track indices where `s1[i] != s2[i]`
|
||||
- Store these indices in a list called `diffs`
|
||||
|
||||
|
||||
|
||||
**Step 2: Analyse the number of differences**
|
||||
|
||||
- If `len(diffs) == 0`: Strings are already equal, return `true`
|
||||
- If `len(diffs) != 2`: Cannot fix with exactly one swap, return `false`
|
||||
- A single swap affects exactly two positions, so we need exactly two differences
|
||||
|
||||
|
||||
|
||||
**Step 3: Verify the cross-match condition**
|
||||
|
||||
- Let `i` and `j` be the two differing indices
|
||||
- Check if `s1[i] == s2[j]` and `s1[j] == s2[i]`
|
||||
- If both conditions hold, swapping positions `i` and `j` in either string makes them equal
|
||||
- Return the result of this check
|
||||
|
||||
common_pitfalls:
|
||||
- title: Forgetting the Zero-Difference Case
|
||||
description: |
|
||||
When both strings are already equal, the answer is `true` — no swap is required.
|
||||
|
||||
Some solutions incorrectly require exactly two differences, but the problem says "at most one" swap. Zero swaps is a valid solution when strings match.
|
||||
wrong_approach: "Requiring exactly 2 differences"
|
||||
correct_approach: "Accept 0 or 2 differences as valid"
|
||||
|
||||
- title: Only Counting Differences Without Verifying Characters
|
||||
description: |
|
||||
Finding exactly two mismatched positions is necessary but not sufficient.
|
||||
|
||||
For example, `s1 = "ab"` and `s2 = "cd"` have two differences, but swapping won't help because the characters don't match across positions. You must verify `s1[i] == s2[j]` and `s1[j] == s2[i]`.
|
||||
wrong_approach: "Only checking if there are exactly 2 differences"
|
||||
correct_approach: "Also verify the cross-match condition"
|
||||
|
||||
- title: Checking for One Difference
|
||||
description: |
|
||||
If there's exactly one position where the strings differ, no single swap can fix it.
|
||||
|
||||
A swap always affects two positions. Swapping two identical characters at the same position is allowed but doesn't help — you'd still have that one mismatch.
|
||||
wrong_approach: "Thinking one difference can be fixed by swapping"
|
||||
correct_approach: "Return false for exactly 1 difference"
|
||||
|
||||
key_takeaways:
|
||||
- "**Count and verify**: When a problem asks about limited operations, count what needs to change and verify the operation can achieve it"
|
||||
- "**Cross-match pattern**: For swap-based problems, the key insight is often that elements must match in a crossed pattern"
|
||||
- "**Edge cases matter**: The zero-difference case (already equal) is easy to overlook but critical"
|
||||
- "**Simple iteration works**: Don't overcomplicate with hash maps when a single pass collecting indices suffices"
|
||||
|
||||
time_complexity: "O(n). We traverse both strings once to find differing positions."
|
||||
space_complexity: "O(1). We store at most 2 indices in the differences list (we can early-exit if more)."
|
||||
|
||||
solutions:
|
||||
- approach_name: Single Pass with Difference Tracking
|
||||
is_optimal: true
|
||||
code: |
|
||||
def are_almost_equal(s1: str, s2: str) -> bool:
|
||||
# Collect indices where characters differ
|
||||
diffs = []
|
||||
|
||||
for i in range(len(s1)):
|
||||
if s1[i] != s2[i]:
|
||||
diffs.append(i)
|
||||
# Early exit: more than 2 differences means impossible
|
||||
if len(diffs) > 2:
|
||||
return False
|
||||
|
||||
# Case 1: Already equal (0 differences)
|
||||
if len(diffs) == 0:
|
||||
return True
|
||||
|
||||
# Case 2: Exactly 1 difference — can't fix with a swap
|
||||
if len(diffs) == 1:
|
||||
return False
|
||||
|
||||
# Case 3: Exactly 2 differences — check cross-match
|
||||
i, j = diffs[0], diffs[1]
|
||||
return s1[i] == s2[j] and s1[j] == s2[i]
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Single pass through both strings.
|
||||
|
||||
**Space Complexity:** O(1) — We store at most 2 indices.
|
||||
|
||||
We iterate once, collecting positions where characters differ. With early exit when we find more than 2 differences, we ensure optimal performance. The final check verifies that swapping would actually make the strings equal.
|
||||
|
||||
- approach_name: Direct Character Comparison
|
||||
is_optimal: true
|
||||
code: |
|
||||
def are_almost_equal(s1: str, s2: str) -> bool:
|
||||
# Find differing positions
|
||||
diffs = [i for i in range(len(s1)) if s1[i] != s2[i]]
|
||||
|
||||
# Already equal
|
||||
if not diffs:
|
||||
return True
|
||||
|
||||
# Must have exactly 2 differences for a swap to work
|
||||
if len(diffs) != 2:
|
||||
return False
|
||||
|
||||
# Verify cross-match: swapping these positions would work
|
||||
i, j = diffs
|
||||
return s1[i] == s2[j] and s1[j] == s2[i]
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — List comprehension iterates through all characters.
|
||||
|
||||
**Space Complexity:** O(n) in worst case — The list stores all differing indices.
|
||||
|
||||
This is a more Pythonic version using list comprehension. While slightly less efficient (no early exit, stores all differences), it's cleaner for small inputs. The logic remains the same: find differences, check count, verify cross-match.
|
||||
161
backend/data/questions/check-if-point-is-reachable.yaml
Normal file
161
backend/data/questions/check-if-point-is-reachable.yaml
Normal file
@@ -0,0 +1,161 @@
|
||||
title: Check if Point Is Reachable
|
||||
slug: check-if-point-is-reachable
|
||||
difficulty: hard
|
||||
leetcode_id: 2543
|
||||
leetcode_url: https://leetcode.com/problems/check-if-point-is-reachable/
|
||||
categories:
|
||||
- math
|
||||
patterns:
|
||||
- greedy
|
||||
|
||||
description: |
|
||||
There exists an infinitely large grid. You are currently at point `(1, 1)`, and you need to reach the point `(targetX, targetY)` using a finite number of steps.
|
||||
|
||||
In one **step**, you can move from point `(x, y)` to any one of the following points:
|
||||
|
||||
- `(x, y - x)`
|
||||
- `(x - y, y)`
|
||||
- `(2 * x, y)`
|
||||
- `(x, 2 * y)`
|
||||
|
||||
Given two integers `targetX` and `targetY` representing the X-coordinate and Y-coordinate of your final position, return `true` *if you can reach the point from* `(1, 1)` *using some number of steps, and* `false` *otherwise*.
|
||||
|
||||
constraints: |
|
||||
- `1 <= targetX, targetY <= 10^9`
|
||||
|
||||
examples:
|
||||
- input: "targetX = 6, targetY = 9"
|
||||
output: "false"
|
||||
explanation: "It is impossible to reach (6, 9) from (1, 1) using any sequence of moves, so false is returned."
|
||||
- input: "targetX = 4, targetY = 7"
|
||||
output: "true"
|
||||
explanation: "You can follow the path (1, 1) -> (1, 2) -> (1, 4) -> (1, 8) -> (1, 7) -> (2, 7) -> (4, 7)."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
This problem initially seems overwhelming — the grid is infinite, the target can be up to 10<sup>9</sup>, and we have four different moves. Simulating all possible paths is clearly impossible.
|
||||
|
||||
The breakthrough comes from **thinking backwards**. Instead of asking "can we reach `(targetX, targetY)` from `(1, 1)`?", ask "can we reach `(1, 1)` from `(targetX, targetY)` using the **reverse** operations?"
|
||||
|
||||
The reverse operations are:
|
||||
- `(x, y)` came from `(x, x + y)` — reverse of `(x, y - x)`
|
||||
- `(x, y)` came from `(x + y, y)` — reverse of `(x - y, y)`
|
||||
- `(x, y)` came from `(x / 2, y)` if `x` is even — reverse of `(2 * x, y)`
|
||||
- `(x, y)` came from `(x, y / 2)` if `y` is even — reverse of `(x, 2 * y)`
|
||||
|
||||
Now notice something remarkable: the first two reverse operations `(x, x + y)` and `(x + y, y)` are exactly the operations in the **Euclidean algorithm** for computing GCD! If we keep applying these operations, we eventually reach `(gcd(x, y), gcd(x, y))`.
|
||||
|
||||
The last two operations let us **divide by 2** as many times as we want. So starting from `(targetX, targetY)`:
|
||||
1. We can reduce to `(gcd(targetX, targetY), gcd(targetX, targetY))` using the GCD operations
|
||||
2. We can then divide by 2 repeatedly to reach `(1, 1)`
|
||||
|
||||
This only works if `gcd(targetX, targetY)` is a **power of 2** (including 2<sup>0</sup> = 1). If the GCD has any odd factor greater than 1, we can never eliminate it.
|
||||
|
||||
approach: |
|
||||
We solve this using **GCD and bit manipulation**:
|
||||
|
||||
**Step 1: Compute the GCD**
|
||||
|
||||
- Calculate `g = gcd(targetX, targetY)` using the Euclidean algorithm
|
||||
- This represents the value we must reduce to `(g, g)` before we can reach `(1, 1)`
|
||||
|
||||
|
||||
|
||||
**Step 2: Check if GCD is a power of 2**
|
||||
|
||||
- A number is a power of 2 if and only if it has exactly one bit set
|
||||
- Use the classic bit trick: `g & (g - 1) == 0` returns `true` for powers of 2
|
||||
- Alternatively, check that `g` has no odd factors (continuously divide by 2 until odd)
|
||||
|
||||
|
||||
|
||||
**Step 3: Return the result**
|
||||
|
||||
- If the GCD is a power of 2, return `true`
|
||||
- Otherwise, return `false`
|
||||
|
||||
|
||||
|
||||
The elegance of this solution is that we reduced a seemingly complex pathfinding problem to a simple mathematical property check.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Attempting BFS/DFS Simulation
|
||||
description: |
|
||||
The instinct to simulate paths using BFS or DFS fails catastrophically here. With coordinates up to 10<sup>9</sup> and infinite branching possibilities, any simulation approach will either run out of memory or time.
|
||||
|
||||
The problem *looks* like a graph traversal but is actually a **number theory** problem in disguise. Recognising when to abandon standard algorithms for mathematical insights is key.
|
||||
wrong_approach: "BFS/DFS to explore all reachable states"
|
||||
correct_approach: "Reverse the problem and analyse GCD properties"
|
||||
|
||||
- title: Missing the Reverse Thinking
|
||||
description: |
|
||||
Working forward from `(1, 1)` is confusing because the state space explodes exponentially. Working backwards from the target is much more tractable because:
|
||||
- Division by 2 shrinks coordinates
|
||||
- The GCD operations have well-understood convergence properties
|
||||
|
||||
Always consider whether reversing the direction of a reachability problem simplifies it.
|
||||
wrong_approach: "Simulate forward from (1, 1)"
|
||||
correct_approach: "Work backwards from (targetX, targetY)"
|
||||
|
||||
- title: Incorrectly Checking Powers of 2
|
||||
description: |
|
||||
Common mistakes when checking if a number is a power of 2:
|
||||
- Forgetting that `1` (2<sup>0</sup>) is a valid power of 2
|
||||
- Using `n % 2 == 0` which only checks if `n` is even, not a power of 2
|
||||
- Using floating-point logarithms which have precision issues
|
||||
|
||||
The bit manipulation trick `n & (n - 1) == 0` is reliable and handles all edge cases (for `n > 0`).
|
||||
wrong_approach: "log2(n) is an integer check"
|
||||
correct_approach: "Bit manipulation: n & (n - 1) == 0"
|
||||
|
||||
key_takeaways:
|
||||
- "**Reverse the problem**: When forward simulation is intractable, working backwards often reveals structure"
|
||||
- "**Recognise GCD operations**: The operations `(x, x + y)` and `(x + y, y)` are the Euclidean algorithm — this pattern appears in many problems"
|
||||
- "**Powers of 2 and bit tricks**: `n & (n - 1) == 0` is the standard way to check if `n` is a power of 2"
|
||||
- "**Number theory in disguise**: Some problems that look like graph traversal are actually about mathematical invariants"
|
||||
|
||||
time_complexity: "O(log(min(targetX, targetY))). Computing GCD using the Euclidean algorithm takes logarithmic time."
|
||||
space_complexity: "O(1). We only use a constant number of variables."
|
||||
|
||||
solutions:
|
||||
- approach_name: GCD Power of 2 Check
|
||||
is_optimal: true
|
||||
code: |
|
||||
from math import gcd
|
||||
|
||||
def is_reachable(target_x: int, target_y: int) -> bool:
|
||||
# Compute GCD of the target coordinates
|
||||
g = gcd(target_x, target_y)
|
||||
|
||||
# Check if GCD is a power of 2
|
||||
# A number is a power of 2 iff it has exactly one bit set
|
||||
# n & (n - 1) clears the lowest set bit; result is 0 only for powers of 2
|
||||
return g & (g - 1) == 0
|
||||
explanation: |
|
||||
**Time Complexity:** O(log(min(targetX, targetY))) — GCD computation dominates.
|
||||
|
||||
**Space Complexity:** O(1) — Only a few integer variables.
|
||||
|
||||
The key insight is that we can always reach `(gcd(x, y), gcd(x, y))` from `(x, y)` using the reverse GCD operations, and from there we can only reach `(1, 1)` if we can divide by 2 enough times — which requires the GCD to be a power of 2.
|
||||
|
||||
- approach_name: Iterative Division Check
|
||||
is_optimal: false
|
||||
code: |
|
||||
from math import gcd
|
||||
|
||||
def is_reachable(target_x: int, target_y: int) -> bool:
|
||||
# Compute GCD of the target coordinates
|
||||
g = gcd(target_x, target_y)
|
||||
|
||||
# Remove all factors of 2 from GCD
|
||||
while g % 2 == 0:
|
||||
g //= 2
|
||||
|
||||
# If only factors of 2, we end up with 1
|
||||
return g == 1
|
||||
explanation: |
|
||||
**Time Complexity:** O(log(min(targetX, targetY))) — GCD computation plus at most log(g) divisions.
|
||||
|
||||
**Space Complexity:** O(1) — Only integer variables used.
|
||||
|
||||
This approach explicitly removes all factors of 2 from the GCD. If we end up with 1, the original GCD was a power of 2. If we end up with something greater than 1, there was an odd prime factor that we cannot eliminate.
|
||||
181
backend/data/questions/check-if-string-is-a-prefix-of-array.yaml
Normal file
181
backend/data/questions/check-if-string-is-a-prefix-of-array.yaml
Normal file
@@ -0,0 +1,181 @@
|
||||
title: Check If String Is a Prefix of Array
|
||||
slug: check-if-string-is-a-prefix-of-array
|
||||
difficulty: easy
|
||||
leetcode_id: 1961
|
||||
leetcode_url: https://leetcode.com/problems/check-if-string-is-a-prefix-of-array/
|
||||
categories:
|
||||
- arrays
|
||||
- strings
|
||||
patterns:
|
||||
- two-pointers
|
||||
|
||||
description: |
|
||||
Given a string `s` and an array of strings `words`, determine whether `s` is a **prefix string** of `words`.
|
||||
|
||||
A string `s` is a **prefix string** of `words` if `s` can be made by concatenating the first `k` strings in `words` for some **positive** `k` no larger than `words.length`.
|
||||
|
||||
Return `true` *if* `s` *is a prefix string of* `words`, *or* `false` *otherwise*.
|
||||
|
||||
constraints: |
|
||||
- `1 <= words.length <= 100`
|
||||
- `1 <= words[i].length <= 20`
|
||||
- `1 <= s.length <= 1000`
|
||||
- `words[i]` and `s` consist of only lowercase English letters.
|
||||
|
||||
examples:
|
||||
- input: 's = "iloveleetcode", words = ["i","love","leetcode","apples"]'
|
||||
output: "true"
|
||||
explanation: "s can be made by concatenating \"i\", \"love\", and \"leetcode\" together."
|
||||
- input: 's = "iloveleetcode", words = ["apples","i","love","leetcode"]'
|
||||
output: "false"
|
||||
explanation: "It is impossible to make s using a prefix of arr."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Think of building a puzzle where you must place pieces strictly from left to right in order.
|
||||
|
||||
You have a target string `s` that you need to recreate using the words in `words`, but there's a crucial rule: you can only use words from the **beginning** of the array, in sequence. You pick the 1<sup>st</sup> word, then optionally the 2<sup>nd</sup>, then optionally the 3<sup>rd</sup>, and so on — never skipping any word or changing the order.
|
||||
|
||||
The core insight is that you're essentially checking if the concatenation of `words[0] + words[1] + ... + words[k-1]` **exactly equals** `s` for some value of `k`. Not a substring, not a partial match — an **exact match**.
|
||||
|
||||
As you concatenate words one by one, you're building up a string. At each step, you check: "Does what I've built so far match `s` exactly?" If yes, you've found your answer. If you've built something longer than `s` or something that doesn't match the prefix of `s`, you can stop early.
|
||||
|
||||
approach: |
|
||||
We solve this using a **Single Pass with String Building**:
|
||||
|
||||
**Step 1: Initialise a result string**
|
||||
|
||||
- `built`: Start with an empty string that we'll grow by appending words
|
||||
|
||||
|
||||
|
||||
**Step 2: Iterate through the words array**
|
||||
|
||||
- For each word in `words`, append it to `built`
|
||||
- After each append, check if `built == s`
|
||||
- If equal, return `true` immediately — we've found our prefix string
|
||||
- If `len(built) > len(s)`, return `false` — we've overshot, no point continuing
|
||||
|
||||
|
||||
|
||||
**Step 3: Return false if loop completes**
|
||||
|
||||
- If we exhaust all words without matching `s` exactly, return `false`
|
||||
|
||||
|
||||
|
||||
This approach is efficient because we stop as soon as we find a match or overshoot the target length. We never do unnecessary work.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Checking for Substring Instead of Exact Match
|
||||
description: |
|
||||
A common mistake is to check if `s` is a *substring* of the concatenation, or if the concatenation *contains* `s`.
|
||||
|
||||
For example, with `s = "i"` and `words = ["i", "love"]`, the concatenation `"ilove"` contains `"i"`, but that's not what we want. We need `s` to be **exactly equal** to the concatenation of some prefix of `words`.
|
||||
|
||||
Always use equality (`==`), not substring checks (`in` or `startswith`).
|
||||
wrong_approach: "Check if s is substring of concatenation"
|
||||
correct_approach: "Check if s equals concatenation exactly"
|
||||
|
||||
- title: Forgetting to Check After Each Word
|
||||
description: |
|
||||
Some implementations concatenate all words first, then check if the result equals `s`. This misses the requirement that `s` must equal the concatenation of the **first k words** for some specific `k`.
|
||||
|
||||
With `s = "ilove"` and `words = ["i", "love", "leetcode"]`, concatenating all gives `"iloveleetcode"`, which doesn't equal `s`. But concatenating just the first 2 words gives `"ilove"`, which does equal `s`.
|
||||
|
||||
You must check for equality **after each word** is added.
|
||||
wrong_approach: "Concatenate all, then check"
|
||||
correct_approach: "Check equality after each concatenation"
|
||||
|
||||
- title: Not Handling the Overshoot Case
|
||||
description: |
|
||||
If the built string becomes longer than `s` but doesn't match, continuing to add more words is wasteful. For efficiency, check if `len(built) > len(s)` and return `false` early.
|
||||
|
||||
This also handles the case where `words` contains very long strings that immediately overshoot `s`.
|
||||
|
||||
key_takeaways:
|
||||
- "**Prefix concatenation**: This pattern of checking prefix concatenations appears in string matching problems — build incrementally and validate at each step"
|
||||
- "**Early termination**: Stop processing as soon as you find a match or determine a match is impossible (overshoot)"
|
||||
- "**Exact match vs. substring**: Be precise about what the problem asks — equality is stricter than containment"
|
||||
- "**Order matters**: The sequential constraint (must use words in order from the start) is what makes this problem tractable"
|
||||
|
||||
time_complexity: "O(n) where n is the length of string `s`. In the worst case, we process characters up to the length of `s` before finding a match or overshooting."
|
||||
space_complexity: "O(n) where n is the length of string `s`. We build a string that grows up to the size of `s`."
|
||||
|
||||
solutions:
|
||||
- approach_name: String Building
|
||||
is_optimal: true
|
||||
code: |
|
||||
def is_prefix_string(s: str, words: list[str]) -> bool:
|
||||
# Build the concatenation incrementally
|
||||
built = ""
|
||||
|
||||
for word in words:
|
||||
# Add the next word to our built string
|
||||
built += word
|
||||
|
||||
# Check if we've matched s exactly
|
||||
if built == s:
|
||||
return True
|
||||
|
||||
# If we've overshot, no point continuing
|
||||
if len(built) > len(s):
|
||||
return False
|
||||
|
||||
# Exhausted all words without matching s
|
||||
return False
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — We process at most n characters where n = len(s).
|
||||
|
||||
**Space Complexity:** O(n) — The built string grows up to the size of s.
|
||||
|
||||
This solution builds the concatenation one word at a time, checking for an exact match after each addition. Early termination on overshoot keeps it efficient.
|
||||
|
||||
- approach_name: Index Tracking
|
||||
is_optimal: true
|
||||
code: |
|
||||
def is_prefix_string(s: str, words: list[str]) -> bool:
|
||||
# Track our position in s
|
||||
index = 0
|
||||
|
||||
for word in words:
|
||||
# Check if this word matches the next part of s
|
||||
if s[index:index + len(word)] != word:
|
||||
return False
|
||||
|
||||
# Move our position forward
|
||||
index += len(word)
|
||||
|
||||
# Check if we've matched all of s
|
||||
if index == len(s):
|
||||
return True
|
||||
|
||||
# Didn't reach the end of s
|
||||
return False
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — We scan through s once using index tracking.
|
||||
|
||||
**Space Complexity:** O(1) — Only using an integer index, no extra string built.
|
||||
|
||||
This approach avoids building a new string by directly comparing slices of `s` with each word. It's more memory-efficient as it uses constant extra space.
|
||||
|
||||
- approach_name: Join and Compare
|
||||
is_optimal: false
|
||||
code: |
|
||||
def is_prefix_string(s: str, words: list[str]) -> bool:
|
||||
# Try each possible prefix length k
|
||||
for k in range(1, len(words) + 1):
|
||||
# Join first k words and compare
|
||||
if "".join(words[:k]) == s:
|
||||
return True
|
||||
# Early exit if we've already exceeded s
|
||||
if len("".join(words[:k])) > len(s):
|
||||
return False
|
||||
|
||||
return False
|
||||
explanation: |
|
||||
**Time Complexity:** O(k * n) — For each k, joining creates a new string.
|
||||
|
||||
**Space Complexity:** O(n) — Each join creates a string up to length n.
|
||||
|
||||
This approach is less efficient because `join` creates a new string for each value of k. The string building approach is better because it incrementally extends the same string.
|
||||
@@ -0,0 +1,197 @@
|
||||
title: Check If String Is Transformable With Substring Sort Operations
|
||||
slug: check-if-string-is-transformable-with-substring-sort-operations
|
||||
difficulty: hard
|
||||
leetcode_id: 1585
|
||||
leetcode_url: https://leetcode.com/problems/check-if-string-is-transformable-with-substring-sort-operations/
|
||||
categories:
|
||||
- strings
|
||||
- sorting
|
||||
patterns:
|
||||
- greedy
|
||||
|
||||
description: |
|
||||
Given two strings `s` and `t`, transform string `s` into string `t` using the following operation any number of times:
|
||||
|
||||
- Choose a **non-empty** substring in `s` and sort it in place so the characters are in **ascending order**.
|
||||
- For example, applying the operation on the underlined substring in `"14234"` results in `"12344"`.
|
||||
|
||||
Return `true` if *it is possible to transform `s` into `t`*. Otherwise, return `false`.
|
||||
|
||||
A **substring** is a contiguous sequence of characters within a string.
|
||||
|
||||
constraints: |
|
||||
- `s.length == t.length`
|
||||
- `1 <= s.length <= 10^5`
|
||||
- `s` and `t` consist of only digits.
|
||||
|
||||
examples:
|
||||
- input: 's = "84532", t = "34852"'
|
||||
output: "true"
|
||||
explanation: 'You can transform s into t using the following sort operations: "84532" (from index 2 to 3) -> "84352", then "84352" (from index 0 to 2) -> "34852".'
|
||||
- input: 's = "34521", t = "23415"'
|
||||
output: "true"
|
||||
explanation: 'You can transform s into t using the following sort operations: "34521" -> "23451" -> "23415".'
|
||||
- input: 's = "12345", t = "12435"'
|
||||
output: "false"
|
||||
explanation: "No sequence of sorting operations can transform s into t because we cannot move a larger digit to the left of a smaller digit."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
The key insight is understanding what the sorting operation **can** and **cannot** do.
|
||||
|
||||
When you sort a substring in ascending order, you're essentially **bubbling smaller characters to the left** within that substring. Think of it like bubble sort: a smaller digit can "pass through" larger digits to its left, one position at a time, by repeatedly sorting adjacent pairs.
|
||||
|
||||
However, the reverse is **impossible**: a larger digit **cannot** move to the left of a smaller digit. Why? Because any sorting operation would push the smaller digit back to the left of the larger one.
|
||||
|
||||
This gives us our fundamental rule: **a digit `d` can only move leftward in `s` if there is no smaller digit blocking its path**.
|
||||
|
||||
Think of it like a queue at each digit position. For each digit `0-9`, we track where instances of that digit appear in `s`. When building `t` character by character, we ask: "Can the next required digit from `s` move to this position?" It can move left if and only if no smaller digit stands in its way (i.e., appears at an earlier index that we haven't yet consumed).
|
||||
|
||||
approach: |
|
||||
We solve this using a **Greedy Index Queue** approach:
|
||||
|
||||
**Step 1: Basic validation**
|
||||
|
||||
- First, verify `s` and `t` have the same character frequencies — if not, transformation is impossible
|
||||
- This ensures we have the right "ingredients" to build `t`
|
||||
|
||||
|
||||
|
||||
**Step 2: Build position queues for each digit**
|
||||
|
||||
- Create 10 queues (one for each digit `0-9`)
|
||||
- For each digit in `s`, append its index to the corresponding queue
|
||||
- This gives us the positions where each digit appears, in left-to-right order
|
||||
|
||||
|
||||
|
||||
**Step 3: Process each character in `t`**
|
||||
|
||||
- For each character `c` in `t`, we need to "consume" one instance of `c` from `s`
|
||||
- Get the leftmost unconsumed position of `c` from its queue (call it `pos`)
|
||||
- **Critical check**: For all digits smaller than `c` (i.e., `0` to `c-1`), verify their queues are either empty or their front index is greater than `pos`
|
||||
- If any smaller digit has an unconsumed position to the left of `pos`, return `false` — that smaller digit blocks `c` from moving left
|
||||
- If the check passes, pop `pos` from `c`'s queue (mark it as consumed)
|
||||
|
||||
|
||||
|
||||
**Step 4: Return the result**
|
||||
|
||||
- If we successfully process all characters in `t`, return `true`
|
||||
|
||||
|
||||
|
||||
The greedy approach works because we always consume the leftmost available instance of each digit. If that instance can legally move to its target position (no smaller blockers), we proceed. Otherwise, no sequence of operations can help.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Ignoring the Movement Constraint
|
||||
description: |
|
||||
A common mistake is thinking any digit can move anywhere as long as character frequencies match.
|
||||
|
||||
For example, with `s = "12345"` and `t = "12435"`, the frequencies match perfectly. But transforming `s` to `t` requires moving `4` to the left of `3`. Since `3 < 4`, the `3` will always sort to the left of `4` — the transformation is impossible.
|
||||
|
||||
Always check whether smaller digits block the required movements.
|
||||
wrong_approach: "Only checking if s and t have the same character counts"
|
||||
correct_approach: "Verify no smaller digit blocks each required leftward movement"
|
||||
|
||||
- title: Using Simulation Instead of Position Tracking
|
||||
description: |
|
||||
Trying to simulate the actual sorting operations leads to exponential complexity. There are infinitely many ways to choose substrings, and finding the right sequence (if it exists) is computationally infeasible for large inputs.
|
||||
|
||||
With `n = 10^5`, any approach worse than O(n) or O(n log n) will likely cause TLE.
|
||||
wrong_approach: "BFS/DFS to explore all possible sorting sequences"
|
||||
correct_approach: "Track digit positions and verify movement constraints in O(n)"
|
||||
|
||||
- title: Checking All Pairs Instead of Using Queues
|
||||
description: |
|
||||
A naive O(n^2) approach checks, for each position in `t`, whether any smaller digit in `s` blocks it by scanning the entire string.
|
||||
|
||||
Using queues for each digit reduces this to O(1) amortised per check — we only look at the front of each smaller digit's queue.
|
||||
wrong_approach: "For each character, scan all of s to find blockers"
|
||||
correct_approach: "Maintain queues of positions, check only queue fronts"
|
||||
|
||||
key_takeaways:
|
||||
- "**Movement analysis**: When operations have constraints (like sorting), analyse what movements are possible vs impossible"
|
||||
- "**Greedy validation**: Instead of simulating operations, verify that required movements don't violate constraints"
|
||||
- "**Queue-based position tracking**: Storing indices in queues enables efficient leftmost-first processing"
|
||||
- "**Digit-based problems**: With only 10 possible digits, maintaining 10 separate data structures is both simple and efficient"
|
||||
|
||||
time_complexity: "O(n). We iterate through `t` once, and each digit position is added to and removed from a queue exactly once. Checking smaller digits is O(10) = O(1) per character."
|
||||
space_complexity: "O(n). We store all indices of `s` across the 10 queues, which totals `n` indices."
|
||||
|
||||
solutions:
|
||||
- approach_name: Greedy with Index Queues
|
||||
is_optimal: true
|
||||
code: |
|
||||
from collections import deque, Counter
|
||||
|
||||
def is_transformable(s: str, t: str) -> bool:
|
||||
# Basic validation: must have same character frequencies
|
||||
if Counter(s) != Counter(t):
|
||||
return False
|
||||
|
||||
# Build queues of positions for each digit (0-9)
|
||||
# pos[d] stores indices where digit d appears in s
|
||||
pos = [deque() for _ in range(10)]
|
||||
for i, c in enumerate(s):
|
||||
pos[int(c)].append(i)
|
||||
|
||||
# Process each character in t
|
||||
for c in t:
|
||||
d = int(c)
|
||||
# Get the leftmost position of digit d in s
|
||||
idx = pos[d][0]
|
||||
|
||||
# Check if any smaller digit blocks this position
|
||||
# A smaller digit blocks if it appears to the left of idx
|
||||
for smaller in range(d):
|
||||
if pos[smaller] and pos[smaller][0] < idx:
|
||||
# Smaller digit at earlier index blocks movement
|
||||
return False
|
||||
|
||||
# No blockers - consume this position
|
||||
pos[d].popleft()
|
||||
|
||||
return True
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — We iterate through both strings once. Each index is enqueued and dequeued exactly once, and we check at most 10 smaller digits per character.
|
||||
|
||||
**Space Complexity:** O(n) — The 10 queues collectively store all n indices from string s.
|
||||
|
||||
The algorithm validates that each character in `t` can be formed by checking whether any smaller digit would block the required leftward movement. By using queues, we efficiently track the leftmost available position for each digit.
|
||||
|
||||
- approach_name: Brute Force (Conceptual)
|
||||
is_optimal: false
|
||||
code: |
|
||||
def is_transformable_brute(s: str, t: str) -> bool:
|
||||
# NOTE: This approach is conceptual and will TLE on large inputs.
|
||||
# It simulates trying all possible sorting operations via BFS.
|
||||
from collections import deque
|
||||
|
||||
if sorted(s) != sorted(t):
|
||||
return False
|
||||
|
||||
visited = {s}
|
||||
queue = deque([s])
|
||||
|
||||
while queue:
|
||||
current = queue.popleft()
|
||||
if current == t:
|
||||
return True
|
||||
|
||||
# Try all possible substring sorts (exponential!)
|
||||
for i in range(len(current)):
|
||||
for j in range(i + 1, len(current) + 1):
|
||||
# Sort substring [i:j]
|
||||
new_s = current[:i] + ''.join(sorted(current[i:j])) + current[j:]
|
||||
if new_s not in visited:
|
||||
visited.add(new_s)
|
||||
queue.append(new_s)
|
||||
|
||||
return False
|
||||
explanation: |
|
||||
**Time Complexity:** O(n! * n^2) in the worst case — exponential number of states, each requiring O(n^2) substring operations.
|
||||
|
||||
**Space Complexity:** O(n! * n) — storing all visited states.
|
||||
|
||||
This BFS approach explores all possible sorting operations. While correct, it's astronomically slow for any non-trivial input size. It's included to illustrate why the greedy approach is essential — we need to reason about what's possible rather than exhaustively searching.
|
||||
151
backend/data/questions/check-if-the-sentence-is-pangram.yaml
Normal file
151
backend/data/questions/check-if-the-sentence-is-pangram.yaml
Normal file
@@ -0,0 +1,151 @@
|
||||
title: Check if the Sentence Is Pangram
|
||||
slug: check-if-the-sentence-is-pangram
|
||||
difficulty: easy
|
||||
leetcode_id: 1832
|
||||
leetcode_url: https://leetcode.com/problems/check-if-the-sentence-is-pangram/
|
||||
categories:
|
||||
- strings
|
||||
- hash-tables
|
||||
patterns:
|
||||
- prefix-sum
|
||||
|
||||
description: |
|
||||
A **pangram** is a sentence where every letter of the English alphabet appears at least once.
|
||||
|
||||
Given a string `sentence` containing only lowercase English letters, return `true` *if* `sentence` *is a **pangram**, or* `false` *otherwise*.
|
||||
|
||||
constraints: |
|
||||
- `1 <= sentence.length <= 1000`
|
||||
- `sentence` consists of lowercase English letters.
|
||||
|
||||
examples:
|
||||
- input: 'sentence = "thequickbrownfoxjumpsoverthelazydog"'
|
||||
output: "true"
|
||||
explanation: "sentence contains at least one of every letter of the English alphabet."
|
||||
- input: 'sentence = "leetcode"'
|
||||
output: "false"
|
||||
explanation: "The sentence only contains 5 unique letters: c, d, e, l, o, t. It's missing 21 letters."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Think of this problem like a checklist: you have 26 boxes, one for each letter of the alphabet from 'a' to 'z'. As you read through the sentence, you're checking off each letter you encounter.
|
||||
|
||||
The question becomes: **after reading the entire sentence, have all 26 boxes been checked?**
|
||||
|
||||
A **set** is the perfect data structure for this task. Sets automatically handle duplicates — if you add the same letter multiple times, it only appears once. So as you iterate through the sentence, you add each character to a set. At the end, if the set contains exactly 26 unique characters, you have a pangram.
|
||||
|
||||
The key insight is that we don't care *how many times* each letter appears, just *whether* it appears at all. This makes the problem a simple membership check rather than a counting problem.
|
||||
|
||||
approach: |
|
||||
We solve this using a **Set-Based Approach**:
|
||||
|
||||
**Step 1: Create a set from the sentence**
|
||||
|
||||
- Convert the sentence into a set of characters
|
||||
- This automatically removes duplicates, leaving only unique letters
|
||||
- In Python, this is as simple as `set(sentence)`
|
||||
|
||||
|
||||
|
||||
**Step 2: Check the size of the set**
|
||||
|
||||
- If the set contains exactly 26 elements, every letter of the alphabet is present
|
||||
- Return `True` if `len(unique_chars) == 26`, otherwise `False`
|
||||
|
||||
|
||||
|
||||
This approach works because the problem guarantees the input contains only lowercase English letters. Since there are exactly 26 such letters, a set of size 26 means all letters are present.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Using a List Instead of a Set
|
||||
description: |
|
||||
A common mistake is to use a list to track seen characters and check membership with `if char not in seen_list`. While this works, it's inefficient.
|
||||
|
||||
List membership checking is **O(n)** per operation, making the overall algorithm **O(n * m)** where m is the number of unique characters seen. With a set, membership checking is **O(1)** on average, keeping the algorithm at **O(n)**.
|
||||
|
||||
For this problem's constraints (n <= 1000), the difference is negligible. But using sets is a good habit for larger inputs.
|
||||
wrong_approach: "List with linear membership checking"
|
||||
correct_approach: "Set with O(1) membership checking"
|
||||
|
||||
- title: Hardcoding the Alphabet
|
||||
description: |
|
||||
Some solutions compare the set against a hardcoded string like `"abcdefghijklmnopqrstuvwxyz"`. While this works, it's error-prone (easy to miss or duplicate a letter) and less elegant.
|
||||
|
||||
Since we know there are exactly 26 lowercase English letters and the input is guaranteed to contain only those letters, simply checking `len(set(sentence)) == 26` is cleaner and less bug-prone.
|
||||
wrong_approach: "Comparing against hardcoded alphabet string"
|
||||
correct_approach: "Check if set size equals 26"
|
||||
|
||||
- title: Forgetting About Case Sensitivity
|
||||
description: |
|
||||
While this problem specifies lowercase-only input, in real-world pangram checking you'd need to handle mixed case. A sentence like "The Quick Brown Fox..." would fail if you don't convert to lowercase first.
|
||||
|
||||
Always read the constraints carefully. Here, the guarantee of lowercase-only input simplifies our solution.
|
||||
|
||||
key_takeaways:
|
||||
- "**Sets for uniqueness**: When you only care about *presence* (not count), sets provide O(1) lookup and automatic deduplication"
|
||||
- "**Know your alphabet**: The English alphabet has exactly 26 letters — this fixed constant enables the simple `len() == 26` check"
|
||||
- "**Read the constraints**: The lowercase-only guarantee eliminates the need for case conversion"
|
||||
- "**Pattern recognition**: This is a classic 'check completeness' pattern — useful for problems asking if all elements of a known set are present"
|
||||
|
||||
time_complexity: "O(n). We iterate through each character in the sentence once to build the set, where n is the length of the sentence."
|
||||
space_complexity: "O(1). The set can contain at most 26 characters (the English alphabet), which is a constant regardless of input size."
|
||||
|
||||
solutions:
|
||||
- approach_name: Set Conversion
|
||||
is_optimal: true
|
||||
code: |
|
||||
def check_if_pangram(sentence: str) -> bool:
|
||||
# Convert sentence to set of unique characters
|
||||
unique_chars = set(sentence)
|
||||
|
||||
# Pangram if all 26 letters are present
|
||||
return len(unique_chars) == 26
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Single pass to build the set.
|
||||
|
||||
**Space Complexity:** O(1) — At most 26 characters in the set.
|
||||
|
||||
This is the most Pythonic solution. The `set()` constructor iterates through the string once, and checking the length is O(1). Clean, readable, and efficient.
|
||||
|
||||
- approach_name: Boolean Array
|
||||
is_optimal: true
|
||||
code: |
|
||||
def check_if_pangram(sentence: str) -> bool:
|
||||
# Track which letters we've seen (index 0 = 'a', index 25 = 'z')
|
||||
seen = [False] * 26
|
||||
|
||||
for char in sentence:
|
||||
# Convert character to index: 'a' -> 0, 'b' -> 1, etc.
|
||||
index = ord(char) - ord('a')
|
||||
seen[index] = True
|
||||
|
||||
# Pangram if all 26 positions are True
|
||||
return all(seen)
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Single pass through the sentence.
|
||||
|
||||
**Space Complexity:** O(1) — Fixed array of 26 booleans.
|
||||
|
||||
This approach uses a boolean array instead of a set. It's useful to understand as it shows how to map characters to array indices using `ord()`. The `all()` function returns `True` only if every element is `True`.
|
||||
|
||||
- approach_name: Bitmask
|
||||
is_optimal: true
|
||||
code: |
|
||||
def check_if_pangram(sentence: str) -> bool:
|
||||
# Use a 32-bit integer as a bitmask
|
||||
# Bit 0 represents 'a', bit 1 represents 'b', etc.
|
||||
seen = 0
|
||||
|
||||
for char in sentence:
|
||||
# Set the bit corresponding to this character
|
||||
bit_position = ord(char) - ord('a')
|
||||
seen |= (1 << bit_position)
|
||||
|
||||
# Check if all 26 bits are set: 2^26 - 1 = 67108863
|
||||
return seen == (1 << 26) - 1
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Single pass through the sentence.
|
||||
|
||||
**Space Complexity:** O(1) — Single integer variable.
|
||||
|
||||
This advanced technique uses bit manipulation to track seen letters. Each bit in the integer represents whether a letter has been seen. The expression `(1 << 26) - 1` creates a number with 26 one-bits, representing all letters present. This is the most space-efficient approach and demonstrates bitmasking patterns.
|
||||
@@ -0,0 +1,303 @@
|
||||
title: Check if There Is a Valid Parentheses String Path
|
||||
slug: check-if-there-is-a-valid-parentheses-string-path
|
||||
difficulty: hard
|
||||
leetcode_id: 2267
|
||||
leetcode_url: https://leetcode.com/problems/check-if-there-is-a-valid-parentheses-string-path/
|
||||
categories:
|
||||
- arrays
|
||||
- dynamic-programming
|
||||
patterns:
|
||||
- dynamic-programming
|
||||
- matrix-traversal
|
||||
- dfs
|
||||
|
||||
description: |
|
||||
A parentheses string is a **non-empty** string consisting only of `'('` and `')'`. It is **valid** if **any** of the following conditions is **true**:
|
||||
|
||||
- It is `()`.
|
||||
- It can be written as `AB` (`A` concatenated with `B`), where `A` and `B` are valid parentheses strings.
|
||||
- It can be written as `(A)`, where `A` is a valid parentheses string.
|
||||
|
||||
You are given an `m x n` matrix of parentheses `grid`. A **valid parentheses string path** in the grid is a path satisfying **all** of the following conditions:
|
||||
|
||||
- The path starts from the upper left cell `(0, 0)`.
|
||||
- The path ends at the bottom-right cell `(m - 1, n - 1)`.
|
||||
- The path only ever moves **down** or **right**.
|
||||
- The resulting parentheses string formed by the path is **valid**.
|
||||
|
||||
Return `true` *if there exists a **valid parentheses string path** in the grid.* Otherwise, return `false`.
|
||||
|
||||
constraints: |
|
||||
- `m == grid.length`
|
||||
- `n == grid[i].length`
|
||||
- `1 <= m, n <= 100`
|
||||
- `grid[i][j]` is either `'('` or `')'`
|
||||
|
||||
examples:
|
||||
- input: 'grid = [["(","(","("],[")","(",")"],["(","(",")"],["(","(",")"]]'
|
||||
output: "true"
|
||||
explanation: "Two possible valid paths: '()(())' and '((()))'. The path starts at (0,0) and ends at (m-1,n-1), moving only right or down."
|
||||
- input: 'grid = [[")",")"],["(","("]]'
|
||||
output: "false"
|
||||
explanation: "The two possible paths form '))('' and ')((''. Neither is a valid parentheses string since they don't have matching pairs in the correct order."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Think of this problem as navigating a maze where each step adds either an opening or closing parenthesis to your running string. The key insight is that you don't need to track the *entire* string you've built — you only need to track the **balance** (the count of unmatched opening parentheses).
|
||||
|
||||
Imagine you're walking through the grid with a counter in hand:
|
||||
- When you step on `'('`, increment the counter (you have one more unmatched open paren)
|
||||
- When you step on `')'`, decrement the counter (you just matched an open paren)
|
||||
|
||||
A valid path must satisfy two conditions:
|
||||
1. The balance **never goes negative** during the walk (you can't close a parenthesis that was never opened)
|
||||
2. The balance is **exactly zero** when you reach the destination (all parentheses are matched)
|
||||
|
||||
The path length is fixed at `m + n - 1` steps (you must move right `n-1` times and down `m-1` times). For a valid parentheses string of this length, you need exactly `(m + n - 1) / 2` opening and closing parentheses. This immediately tells us that if `m + n - 1` is odd, no valid path can exist.
|
||||
|
||||
We use **dynamic programming with memoisation**: at each cell `(i, j)`, we track which balance values are achievable. If we can reach the bottom-right corner with balance `0`, a valid path exists.
|
||||
|
||||
approach: |
|
||||
We solve this using **DFS with Memoisation** or equivalently **3D Dynamic Programming**:
|
||||
|
||||
**Step 1: Early termination checks**
|
||||
|
||||
- If `(m + n - 1)` is odd, return `false` — a valid parentheses string must have even length
|
||||
- If `grid[0][0] == ')'`, return `false` — path must start with `'('`
|
||||
- If `grid[m-1][n-1] == '('`, return `false` — path must end with `')'`
|
||||
|
||||
|
||||
|
||||
**Step 2: Define the state**
|
||||
|
||||
- State: `(row, col, balance)` where `balance` is the count of unmatched `'('`
|
||||
- At each cell, the balance is updated based on the character: `+1` for `'('`, `-1` for `')'`
|
||||
- We use a set or 3D boolean array to track visited states
|
||||
|
||||
|
||||
|
||||
**Step 3: DFS with pruning**
|
||||
|
||||
- From each cell, try moving right `(row, col+1)` and down `(row+1, col)`
|
||||
- Prune paths where:
|
||||
- `balance < 0` (too many closing parentheses)
|
||||
- `balance > (m - row) + (n - col) - 1` (not enough cells remaining to close all open parens)
|
||||
- Use memoisation to avoid recomputing the same `(row, col, balance)` states
|
||||
|
||||
|
||||
|
||||
**Step 4: Check the destination**
|
||||
|
||||
- Return `true` if we can reach `(m-1, n-1)` with `balance == 0`
|
||||
|
||||
|
||||
|
||||
The upper bound on balance is `(m + n) / 2` since you can have at most that many unmatched opening parentheses. This bounds our state space to `O(m × n × (m + n))`.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Tracking the Full String
|
||||
description: |
|
||||
A naive approach might try to build and validate the actual parentheses string along each path. With up to `2^(m+n-2)` possible paths (at each non-boundary cell you have 2 choices), this leads to exponential time complexity.
|
||||
|
||||
Instead, recognise that you only need the **balance** (count of unmatched `'('`). The specific characters don't matter — only how many unmatched opens remain.
|
||||
wrong_approach: "Building strings and checking validity at the end"
|
||||
correct_approach: "Track integer balance, prune when negative"
|
||||
|
||||
- title: Missing the Odd Length Check
|
||||
description: |
|
||||
A valid parentheses string must have even length. The path length is always `m + n - 1`. If this is odd, no valid path can exist.
|
||||
|
||||
Checking this upfront avoids unnecessary computation.
|
||||
wrong_approach: "Searching all paths even when solution is impossible"
|
||||
correct_approach: "Return false immediately if (m + n - 1) is odd"
|
||||
|
||||
- title: Insufficient Pruning
|
||||
description: |
|
||||
Without pruning, the algorithm explores many hopeless paths. Key pruning rules:
|
||||
|
||||
1. If `balance < 0`, stop — we've closed more than we've opened
|
||||
2. If `balance > remaining_steps`, stop — not enough cells left to close all opens
|
||||
|
||||
The second rule is often forgotten but dramatically reduces the search space.
|
||||
wrong_approach: "Only pruning on negative balance"
|
||||
correct_approach: "Also prune when balance exceeds remaining path length"
|
||||
|
||||
- title: Forgetting to Memoise
|
||||
description: |
|
||||
Without memoisation, the same `(row, col, balance)` state can be reached via many different paths. The algorithm degenerates to exponential time.
|
||||
|
||||
With memoisation, each state is processed at most once, giving polynomial time complexity.
|
||||
wrong_approach: "Plain DFS without caching visited states"
|
||||
correct_approach: "Use a set or 3D array to track (row, col, balance) states"
|
||||
|
||||
key_takeaways:
|
||||
- "**State reduction**: Instead of tracking the full string, reduce the state to a single integer (balance). This is a common technique when only aggregate properties matter."
|
||||
- "**Pruning is essential**: The upper bound on balance (`remaining_steps`) and the lower bound (`0`) dramatically prune the search space."
|
||||
- "**3D DP on grids**: When grid problems have an additional dimension of state (here, balance), think of it as 3D DP: `dp[row][col][state]`."
|
||||
- "**Early termination**: Simple checks like odd path length or wrong start/end characters can immediately rule out solutions."
|
||||
|
||||
time_complexity: "O(m × n × (m + n)). Each cell can have at most `(m + n) / 2` distinct balance values, and we visit each `(row, col, balance)` state at most once."
|
||||
space_complexity: "O(m × n × (m + n)). We store the memoisation set containing up to `m × n × (m + n) / 2` states. The recursion stack adds O(m + n) depth."
|
||||
|
||||
solutions:
|
||||
- approach_name: DFS with Memoisation
|
||||
is_optimal: true
|
||||
code: |
|
||||
def hasValidPath(grid: list[list[str]]) -> bool:
|
||||
m, n = len(grid), len(grid[0])
|
||||
|
||||
# Path length must be even for valid parentheses
|
||||
if (m + n - 1) % 2 == 1:
|
||||
return False
|
||||
|
||||
# Must start with '(' and end with ')'
|
||||
if grid[0][0] == ')' or grid[m - 1][n - 1] == '(':
|
||||
return False
|
||||
|
||||
# Memoisation: track visited (row, col, balance) states
|
||||
visited = set()
|
||||
|
||||
def dfs(row: int, col: int, balance: int) -> bool:
|
||||
# Update balance based on current cell
|
||||
if grid[row][col] == '(':
|
||||
balance += 1
|
||||
else:
|
||||
balance -= 1
|
||||
|
||||
# Prune: too many closing parens
|
||||
if balance < 0:
|
||||
return False
|
||||
|
||||
# Prune: not enough cells left to close all opens
|
||||
remaining = (m - 1 - row) + (n - 1 - col)
|
||||
if balance > remaining:
|
||||
return False
|
||||
|
||||
# Reached destination with balanced parentheses
|
||||
if row == m - 1 and col == n - 1:
|
||||
return balance == 0
|
||||
|
||||
# Skip already visited states
|
||||
state = (row, col, balance)
|
||||
if state in visited:
|
||||
return False
|
||||
visited.add(state)
|
||||
|
||||
# Try moving right and down
|
||||
if col + 1 < n and dfs(row, col + 1, balance):
|
||||
return True
|
||||
if row + 1 < m and dfs(row + 1, col, balance):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
return dfs(0, 0, 0)
|
||||
explanation: |
|
||||
**Time Complexity:** O(m × n × (m + n)) — Each unique `(row, col, balance)` state is visited at most once. Balance ranges from `0` to `(m + n) / 2`.
|
||||
|
||||
**Space Complexity:** O(m × n × (m + n)) — The memoisation set stores up to `m × n × (m + n) / 2` states, plus O(m + n) recursion depth.
|
||||
|
||||
The DFS explores all paths while pruning invalid branches early. The key optimisation is recognising that balance cannot exceed the remaining path length, which bounds the state space.
|
||||
|
||||
- approach_name: Bottom-Up DP with Sets
|
||||
is_optimal: true
|
||||
code: |
|
||||
def hasValidPath(grid: list[list[str]]) -> bool:
|
||||
m, n = len(grid), len(grid[0])
|
||||
|
||||
# Path length must be even for valid parentheses
|
||||
if (m + n - 1) % 2 == 1:
|
||||
return False
|
||||
|
||||
if grid[0][0] == ')' or grid[m - 1][n - 1] == '(':
|
||||
return False
|
||||
|
||||
# dp[i][j] = set of achievable balances at cell (i, j)
|
||||
dp = [[set() for _ in range(n)] for _ in range(m)]
|
||||
|
||||
# Initialise starting cell
|
||||
dp[0][0].add(1) # grid[0][0] is '(' so balance starts at 1
|
||||
|
||||
for i in range(m):
|
||||
for j in range(n):
|
||||
if i == 0 and j == 0:
|
||||
continue
|
||||
|
||||
char_val = 1 if grid[i][j] == '(' else -1
|
||||
|
||||
# Collect balances from cells above and to the left
|
||||
prev_balances = set()
|
||||
if i > 0:
|
||||
prev_balances |= dp[i - 1][j]
|
||||
if j > 0:
|
||||
prev_balances |= dp[i][j - 1]
|
||||
|
||||
# Compute new balances, pruning invalid ones
|
||||
remaining = (m - 1 - i) + (n - 1 - j)
|
||||
for bal in prev_balances:
|
||||
new_bal = bal + char_val
|
||||
# Prune: balance must be non-negative and achievable
|
||||
if 0 <= new_bal <= remaining:
|
||||
dp[i][j].add(new_bal)
|
||||
|
||||
# Check if we can reach destination with balance 0
|
||||
return 0 in dp[m - 1][n - 1]
|
||||
explanation: |
|
||||
**Time Complexity:** O(m × n × (m + n)) — For each cell, we process up to `(m + n) / 2` balance values.
|
||||
|
||||
**Space Complexity:** O(m × n × (m + n)) — The DP table stores sets of balances for each cell.
|
||||
|
||||
This iterative approach builds up achievable balances cell by cell. At each cell, we combine balances from the cell above and to the left, update based on the current character, and prune impossible states.
|
||||
|
||||
- approach_name: Space-Optimised DP
|
||||
is_optimal: false
|
||||
code: |
|
||||
def hasValidPath(grid: list[list[str]]) -> bool:
|
||||
m, n = len(grid), len(grid[0])
|
||||
|
||||
if (m + n - 1) % 2 == 1:
|
||||
return False
|
||||
|
||||
if grid[0][0] == ')' or grid[m - 1][n - 1] == '(':
|
||||
return False
|
||||
|
||||
# Only keep current and previous row
|
||||
prev_row = [set() for _ in range(n)]
|
||||
curr_row = [set() for _ in range(n)]
|
||||
|
||||
prev_row[0].add(1) # Starting balance after '('
|
||||
|
||||
for i in range(m):
|
||||
for j in range(n):
|
||||
if i == 0 and j == 0:
|
||||
curr_row[0] = prev_row[0].copy()
|
||||
continue
|
||||
|
||||
char_val = 1 if grid[i][j] == '(' else -1
|
||||
curr_row[j] = set()
|
||||
|
||||
# From above (previous row)
|
||||
if i > 0:
|
||||
for bal in prev_row[j]:
|
||||
new_bal = bal + char_val
|
||||
remaining = (m - 1 - i) + (n - 1 - j)
|
||||
if 0 <= new_bal <= remaining:
|
||||
curr_row[j].add(new_bal)
|
||||
|
||||
# From left (current row)
|
||||
if j > 0:
|
||||
for bal in curr_row[j - 1]:
|
||||
new_bal = bal + char_val
|
||||
remaining = (m - 1 - i) + (n - 1 - j)
|
||||
if 0 <= new_bal <= remaining:
|
||||
curr_row[j].add(new_bal)
|
||||
|
||||
prev_row, curr_row = curr_row, [set() for _ in range(n)]
|
||||
|
||||
return 0 in prev_row[n - 1]
|
||||
explanation: |
|
||||
**Time Complexity:** O(m × n × (m + n)) — Same as the full DP approach.
|
||||
|
||||
**Space Complexity:** O(n × (m + n)) — Only stores two rows at a time instead of the full grid.
|
||||
|
||||
This optimisation reduces memory usage by observing that each cell only depends on the cell above and to the left. We only need to keep track of the current row and the previous row.
|
||||
@@ -0,0 +1,226 @@
|
||||
title: Check if There is a Valid Partition For The Array
|
||||
slug: check-if-there-is-a-valid-partition-for-the-array
|
||||
difficulty: medium
|
||||
leetcode_id: 2369
|
||||
leetcode_url: https://leetcode.com/problems/check-if-there-is-a-valid-partition-for-the-array/
|
||||
categories:
|
||||
- arrays
|
||||
- dynamic-programming
|
||||
patterns:
|
||||
- dynamic-programming
|
||||
|
||||
description: |
|
||||
You are given a **0-indexed** integer array `nums`. You have to partition the array into one or more **contiguous** subarrays.
|
||||
|
||||
We call a partition of the array **valid** if each of the obtained subarrays satisfies **one** of the following conditions:
|
||||
|
||||
1. The subarray consists of **exactly** `2` equal elements. For example, the subarray `[2,2]` is good.
|
||||
2. The subarray consists of **exactly** `3` equal elements. For example, the subarray `[4,4,4]` is good.
|
||||
3. The subarray consists of **exactly** `3` consecutive increasing elements, that is, the difference between adjacent elements is `1`. For example, the subarray `[3,4,5]` is good, but the subarray `[1,3,5]` is not.
|
||||
|
||||
Return `true` *if the array has **at least** one valid partition*. Otherwise, return `false`.
|
||||
|
||||
constraints: |
|
||||
- `2 <= nums.length <= 10^5`
|
||||
- `1 <= nums[i] <= 10^6`
|
||||
|
||||
examples:
|
||||
- input: "nums = [4,4,4,5,6]"
|
||||
output: "true"
|
||||
explanation: "The array can be partitioned into the subarrays [4,4] and [4,5,6]. This partition is valid, so we return true."
|
||||
- input: "nums = [1,1,1,2]"
|
||||
output: "false"
|
||||
explanation: "There is no valid partition for this array."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Imagine you're walking along the array from left to right, deciding at each position: "Can I end a valid subarray here?"
|
||||
|
||||
The key insight is that this is a **decision problem with overlapping subproblems**. Whether you can validly partition the array up to position `i` depends on whether you could validly partition up to positions `i-2` or `i-3` — and whether the last 2 or 3 elements form a valid group.
|
||||
|
||||
Think of it like building with blocks: you can only place a new block (2-element or 3-element subarray) if the foundation beneath it is solid (previous positions can be validly partitioned). This recursive structure with overlapping subproblems is the hallmark of dynamic programming.
|
||||
|
||||
The three valid "blocks" you can place are:
|
||||
- Two equal elements `[a, a]`
|
||||
- Three equal elements `[a, a, a]`
|
||||
- Three consecutive elements `[a, a+1, a+2]`
|
||||
|
||||
At each position, you check if any of these blocks can end here, and if so, whether the rest of the array before that block is also valid.
|
||||
|
||||
approach: |
|
||||
We solve this using **Dynamic Programming** where `dp[i]` represents whether the subarray `nums[0..i-1]` (the first `i` elements) can be validly partitioned.
|
||||
|
||||
**Step 1: Initialise the DP array**
|
||||
|
||||
- `dp[0]`: Set to `True` — an empty prefix is trivially valid (base case)
|
||||
- `dp[1..n]`: Set to `False` initially — we'll determine validity through transitions
|
||||
|
||||
|
||||
|
||||
**Step 2: Define helper functions for valid subarrays**
|
||||
|
||||
- `two_equal(i)`: Check if `nums[i-1] == nums[i-2]` (last two elements are equal)
|
||||
- `three_equal(i)`: Check if `nums[i-1] == nums[i-2] == nums[i-3]` (last three elements are equal)
|
||||
- `three_consecutive(i)`: Check if `nums[i-3] + 1 == nums[i-2]` and `nums[i-2] + 1 == nums[i-1]` (last three form a consecutive sequence)
|
||||
|
||||
|
||||
|
||||
**Step 3: Fill the DP array**
|
||||
|
||||
- For each position `i` from `2` to `n`:
|
||||
- If `i >= 2` and `two_equal(i)` and `dp[i-2]` is `True`, set `dp[i] = True`
|
||||
- If `i >= 3` and `three_equal(i)` and `dp[i-3]` is `True`, set `dp[i] = True`
|
||||
- If `i >= 3` and `three_consecutive(i)` and `dp[i-3]` is `True`, set `dp[i] = True`
|
||||
|
||||
|
||||
|
||||
**Step 4: Return the result**
|
||||
|
||||
- Return `dp[n]` — whether the entire array can be validly partitioned
|
||||
|
||||
|
||||
|
||||
This approach works because we systematically check all possible ways to end a valid subarray at each position, building on previously computed valid partitions.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Greedy Doesn't Work
|
||||
description: |
|
||||
A tempting approach is to greedily pick the first valid subarray you find and continue. For example, with `[4,4,4,5,6]`, you might greedily take `[4,4,4]` leaving `[5,6]` which is invalid.
|
||||
|
||||
But the correct partition is `[4,4]` + `[4,5,6]`. Greedy fails because the optimal choice at one position depends on future elements. Dynamic programming explores all possibilities.
|
||||
wrong_approach: "Greedily pick first valid subarray found"
|
||||
correct_approach: "Use DP to consider all valid partition combinations"
|
||||
|
||||
- title: Off-by-One Index Errors
|
||||
description: |
|
||||
The DP recurrence involves looking back 2 or 3 positions. It's easy to make index errors when checking `nums[i-1]`, `nums[i-2]`, `nums[i-3]`.
|
||||
|
||||
Be careful: if `dp[i]` represents the first `i` elements, then `nums[i-1]` is the last element in that prefix. When checking two equal elements, you compare `nums[i-1]` and `nums[i-2]`, not `nums[i]` and `nums[i-1]`.
|
||||
wrong_approach: "Inconsistent indexing between dp array and nums array"
|
||||
correct_approach: "Clearly define what dp[i] represents and derive indices consistently"
|
||||
|
||||
- title: Forgetting the Base Case
|
||||
description: |
|
||||
Without `dp[0] = True`, no partition can ever be valid. The DP transitions require that `dp[i-2]` or `dp[i-3]` be `True` for `dp[i]` to become `True`. If `dp[0]` is `False`, the chain breaks.
|
||||
|
||||
`dp[0] = True` means "partitioning zero elements is trivially valid" — it's the foundation that allows the first 2 or 3 element group to be placed.
|
||||
|
||||
key_takeaways:
|
||||
- "**DP for partition problems**: When deciding how to split an array, define `dp[i]` as whether the prefix of length `i` can be validly partitioned"
|
||||
- "**Multiple transitions**: At each position, check all valid ways to end a subarray and combine with previous DP states"
|
||||
- "**Greedy fails for partitioning**: The optimal local choice doesn't guarantee a global solution when future elements matter"
|
||||
- "**Space optimisation possible**: Since we only look back 3 positions, we can reduce space from O(n) to O(1) using rolling variables"
|
||||
|
||||
time_complexity: "O(n). We iterate through the array once, performing O(1) checks at each position."
|
||||
space_complexity: "O(n) for the DP array. Can be optimised to O(1) by only keeping the last 3 DP values."
|
||||
|
||||
solutions:
|
||||
- approach_name: Dynamic Programming
|
||||
is_optimal: true
|
||||
code: |
|
||||
def valid_partition(nums: list[int]) -> bool:
|
||||
n = len(nums)
|
||||
# dp[i] = True if nums[0..i-1] can be validly partitioned
|
||||
dp = [False] * (n + 1)
|
||||
# Base case: empty prefix is valid
|
||||
dp[0] = True
|
||||
|
||||
for i in range(2, n + 1):
|
||||
# Check if last 2 elements are equal
|
||||
if nums[i - 1] == nums[i - 2] and dp[i - 2]:
|
||||
dp[i] = True
|
||||
|
||||
# Check if last 3 elements are equal
|
||||
if i >= 3 and nums[i - 1] == nums[i - 2] == nums[i - 3] and dp[i - 3]:
|
||||
dp[i] = True
|
||||
|
||||
# Check if last 3 elements are consecutive increasing
|
||||
if i >= 3:
|
||||
if nums[i - 3] + 1 == nums[i - 2] and nums[i - 2] + 1 == nums[i - 1]:
|
||||
if dp[i - 3]:
|
||||
dp[i] = True
|
||||
|
||||
return dp[n]
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Single pass through the array with O(1) work at each step.
|
||||
|
||||
**Space Complexity:** O(n) — DP array of size n+1.
|
||||
|
||||
We build up the solution by checking at each position whether we can end a valid 2-element or 3-element subarray, combining with previously computed valid partitions.
|
||||
|
||||
- approach_name: Space-Optimised DP
|
||||
is_optimal: true
|
||||
code: |
|
||||
def valid_partition(nums: list[int]) -> bool:
|
||||
n = len(nums)
|
||||
# Only need last 3 dp values: dp[i-3], dp[i-2], dp[i-1]
|
||||
# Represent as dp0, dp1, dp2 where dp2 is most recent
|
||||
dp0, dp1, dp2 = True, False, False
|
||||
|
||||
for i in range(2, n + 1):
|
||||
current = False
|
||||
|
||||
# Check if last 2 elements are equal
|
||||
if nums[i - 1] == nums[i - 2] and dp1:
|
||||
current = True
|
||||
|
||||
# Check if last 3 elements are equal
|
||||
if i >= 3 and nums[i - 1] == nums[i - 2] == nums[i - 3] and dp0:
|
||||
current = True
|
||||
|
||||
# Check if last 3 elements are consecutive
|
||||
if i >= 3:
|
||||
if nums[i - 3] + 1 == nums[i - 2] and nums[i - 2] + 1 == nums[i - 1]:
|
||||
if dp0:
|
||||
current = True
|
||||
|
||||
# Shift the window
|
||||
dp0, dp1, dp2 = dp1, dp2, current
|
||||
|
||||
return dp2
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Same as standard DP approach.
|
||||
|
||||
**Space Complexity:** O(1) — Only three variables instead of an array.
|
||||
|
||||
Since we only ever look back at most 3 positions in our DP array, we can use a sliding window of 3 variables instead of storing the entire array. This reduces memory usage from O(n) to O(1).
|
||||
|
||||
- approach_name: Recursive with Memoisation
|
||||
is_optimal: false
|
||||
code: |
|
||||
def valid_partition(nums: list[int]) -> bool:
|
||||
n = len(nums)
|
||||
memo = {}
|
||||
|
||||
def can_partition(start: int) -> bool:
|
||||
# Base case: reached the end
|
||||
if start == n:
|
||||
return True
|
||||
if start in memo:
|
||||
return memo[start]
|
||||
|
||||
result = False
|
||||
|
||||
# Try 2 equal elements
|
||||
if start + 2 <= n and nums[start] == nums[start + 1]:
|
||||
result = result or can_partition(start + 2)
|
||||
|
||||
# Try 3 equal elements
|
||||
if start + 3 <= n and nums[start] == nums[start + 1] == nums[start + 2]:
|
||||
result = result or can_partition(start + 3)
|
||||
|
||||
# Try 3 consecutive elements
|
||||
if start + 3 <= n:
|
||||
if nums[start] + 1 == nums[start + 1] and nums[start + 1] + 1 == nums[start + 2]:
|
||||
result = result or can_partition(start + 3)
|
||||
|
||||
memo[start] = result
|
||||
return result
|
||||
|
||||
return can_partition(0)
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Each starting position is computed once due to memoisation.
|
||||
|
||||
**Space Complexity:** O(n) — Memoisation dictionary and recursion stack.
|
||||
|
||||
This top-down approach recursively tries all valid partitions from each starting position. Memoisation prevents redundant computation. While equivalent in complexity, the iterative DP approach is generally preferred for avoiding recursion stack overhead.
|
||||
@@ -0,0 +1,313 @@
|
||||
title: Check if There is a Valid Path in a Grid
|
||||
slug: check-if-there-is-a-valid-path-in-a-grid
|
||||
difficulty: medium
|
||||
leetcode_id: 1391
|
||||
leetcode_url: https://leetcode.com/problems/check-if-there-is-a-valid-path-in-a-grid/
|
||||
categories:
|
||||
- arrays
|
||||
- graphs
|
||||
patterns:
|
||||
- bfs
|
||||
- dfs
|
||||
- matrix-traversal
|
||||
- union-find
|
||||
|
||||
description: |
|
||||
You are given an `m x n` `grid`. Each cell of `grid` represents a street. The street of `grid[i][j]` can be:
|
||||
|
||||
- `1` which means a street connecting the **left** cell and the **right** cell.
|
||||
- `2` which means a street connecting the **upper** cell and the **lower** cell.
|
||||
- `3` which means a street connecting the **left** cell and the **lower** cell.
|
||||
- `4` which means a street connecting the **right** cell and the **lower** cell.
|
||||
- `5` which means a street connecting the **left** cell and the **upper** cell.
|
||||
- `6` which means a street connecting the **right** cell and the **upper** cell.
|
||||
|
||||
You will initially start at the street of the upper-left cell `(0, 0)`. A valid path in the grid is a path that starts from the upper left cell `(0, 0)` and ends at the bottom-right cell `(m - 1, n - 1)`. **The path should only follow the streets**.
|
||||
|
||||
**Notice** that you are **not allowed** to change any street.
|
||||
|
||||
Return `true` *if there is a valid path in the grid or* `false` *otherwise*.
|
||||
|
||||
constraints: |
|
||||
- `m == grid.length`
|
||||
- `n == grid[i].length`
|
||||
- `1 <= m, n <= 300`
|
||||
- `1 <= grid[i][j] <= 6`
|
||||
|
||||
examples:
|
||||
- input: "grid = [[2,4,3],[6,5,2]]"
|
||||
output: "true"
|
||||
explanation: "You can start at cell (0, 0) and visit all the cells of the grid to reach (m - 1, n - 1) by following the connected streets."
|
||||
- input: "grid = [[1,2,1],[1,2,1]]"
|
||||
output: "false"
|
||||
explanation: "The street at cell (0, 0) is not connected with any street of any other cell and you will get stuck at cell (0, 0)."
|
||||
- input: "grid = [[1,1,2]]"
|
||||
output: "false"
|
||||
explanation: "You will get stuck at cell (0, 1) and you cannot reach cell (0, 2)."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Imagine each cell as a **pipe segment** in a plumbing system. Each pipe type connects two specific sides of the cell — some go left-right, some go up-down, and others bend in various L-shapes.
|
||||
|
||||
For water (or a path) to flow from one cell to another, **both pipes must connect at their shared edge**. If cell A has a pipe opening on its right side, cell B (to the right of A) must have a pipe opening on its left side for them to be connected.
|
||||
|
||||
The key insight is that this is a **bidirectional connectivity problem**. When moving from cell A to cell B:
|
||||
1. Cell A must have an opening toward B (the direction we want to go)
|
||||
2. Cell B must have an opening back toward A (the direction we came from)
|
||||
|
||||
Think of it like puzzle pieces — the edges must match up. A pipe that opens right (`→`) can only connect to a neighbour that opens left (`←`).
|
||||
|
||||
We can solve this using either BFS/DFS (traversing connected cells) or Union-Find (grouping connected cells), checking at each step that both the source and destination cells have compatible openings.
|
||||
|
||||
approach: |
|
||||
We solve this using **BFS with directional validation**:
|
||||
|
||||
**Step 1: Define pipe directions**
|
||||
|
||||
- Create a mapping for each pipe type (1-6) to the directions it connects
|
||||
- Directions: `0` = up, `1` = right, `2` = down, `3` = left
|
||||
- Pipe 1: `{1, 3}` (left-right), Pipe 2: `{0, 2}` (up-down), etc.
|
||||
|
||||
|
||||
|
||||
**Step 2: Define opposite directions**
|
||||
|
||||
- When moving right (direction 1), the target must accept from left (direction 3)
|
||||
- Opposites: `0 ↔ 2` (up ↔ down), `1 ↔ 3` (right ↔ left)
|
||||
|
||||
|
||||
|
||||
**Step 3: BFS traversal**
|
||||
|
||||
- Start from `(0, 0)` and add it to the queue
|
||||
- For each cell, check all directions its pipe connects to
|
||||
- For each direction, calculate the neighbour cell coordinates
|
||||
- Verify the neighbour is in bounds and the neighbour's pipe connects back
|
||||
- If valid and unvisited, add to queue and mark as visited
|
||||
|
||||
|
||||
|
||||
**Step 4: Check destination**
|
||||
|
||||
- If we reach `(m-1, n-1)`, return `true`
|
||||
- If the queue empties without reaching it, return `false`
|
||||
|
||||
common_pitfalls:
|
||||
- title: Forgetting Bidirectional Validation
|
||||
description: |
|
||||
A common mistake is only checking if the current cell can move in a direction, without verifying the target cell accepts connections from that direction.
|
||||
|
||||
For example, if cell `(0, 0)` has pipe type 1 (left-right), you might try moving right to `(0, 1)`. But if `(0, 1)` has pipe type 2 (up-down), there's no connection — pipe 2 doesn't have a left opening.
|
||||
|
||||
Always check **both sides** of the connection.
|
||||
wrong_approach: "Only checking if current cell has an opening in the movement direction"
|
||||
correct_approach: "Verify both current cell's exit direction AND neighbour's entry direction match"
|
||||
|
||||
- title: Incorrect Direction Mapping
|
||||
description: |
|
||||
The six pipe types have specific connection patterns that must be encoded correctly:
|
||||
|
||||
- Type 1: left ↔ right (horizontal)
|
||||
- Type 2: up ↔ down (vertical)
|
||||
- Type 3: left ↔ down (L-bend)
|
||||
- Type 4: right ↔ down (L-bend)
|
||||
- Type 5: left ↔ up (L-bend)
|
||||
- Type 6: right ↔ up (L-bend)
|
||||
|
||||
Getting even one mapping wrong will cause incorrect path detection.
|
||||
wrong_approach: "Guessing pipe directions without careful mapping"
|
||||
correct_approach: "Create explicit direction sets for each pipe type based on the problem description"
|
||||
|
||||
- title: Not Handling Single Cell Grid
|
||||
description: |
|
||||
When the grid is `1x1`, the start and end are the same cell. The path is trivially valid regardless of the pipe type.
|
||||
|
||||
Make sure to handle this edge case by checking if `(0, 0) == (m-1, n-1)` at the start.
|
||||
wrong_approach: "Assuming there's always movement required"
|
||||
correct_approach: "Return true immediately if start equals destination"
|
||||
|
||||
key_takeaways:
|
||||
- "**Bidirectional validation**: In connectivity problems with directional constraints, verify both source and destination agree on the connection"
|
||||
- "**Direction encoding**: Use consistent direction indices (0=up, 1=right, 2=down, 3=left) and precompute direction deltas `[(-1,0), (0,1), (1,0), (0,-1)]`"
|
||||
- "**BFS vs Union-Find**: Both work here — BFS explores reachable cells, Union-Find groups connected cells. BFS is more intuitive; Union-Find can be faster for multiple queries"
|
||||
- "**Pattern recognition**: This is a specialised graph traversal where edges have compatibility constraints, similar to pipe puzzles or tile-matching games"
|
||||
|
||||
time_complexity: "O(m × n). Each cell is visited at most once during BFS traversal, and we perform constant-time direction checks per cell."
|
||||
space_complexity: "O(m × n). We store a visited set that can grow up to the size of the grid, plus the BFS queue which in the worst case holds O(min(m, n)) elements."
|
||||
|
||||
solutions:
|
||||
- approach_name: BFS with Direction Validation
|
||||
is_optimal: true
|
||||
code: |
|
||||
from collections import deque
|
||||
|
||||
def has_valid_path(grid: list[list[int]]) -> bool:
|
||||
m, n = len(grid), len(grid[0])
|
||||
|
||||
# Single cell grid is always valid
|
||||
if m == 1 and n == 1:
|
||||
return True
|
||||
|
||||
# Direction indices: 0=up, 1=right, 2=down, 3=left
|
||||
# Each pipe type maps to the directions it connects
|
||||
pipe_dirs = {
|
||||
1: {1, 3}, # left-right (horizontal)
|
||||
2: {0, 2}, # up-down (vertical)
|
||||
3: {2, 3}, # left-down
|
||||
4: {1, 2}, # right-down
|
||||
5: {0, 3}, # left-up
|
||||
6: {0, 1}, # right-up
|
||||
}
|
||||
|
||||
# Direction deltas: [up, right, down, left]
|
||||
deltas = [(-1, 0), (0, 1), (1, 0), (0, -1)]
|
||||
|
||||
# Opposite directions for bidirectional check
|
||||
opposite = {0: 2, 1: 3, 2: 0, 3: 1}
|
||||
|
||||
visited = {(0, 0)}
|
||||
queue = deque([(0, 0)])
|
||||
|
||||
while queue:
|
||||
r, c = queue.popleft()
|
||||
|
||||
# Check if we've reached the destination
|
||||
if r == m - 1 and c == n - 1:
|
||||
return True
|
||||
|
||||
# Get directions current pipe connects to
|
||||
current_pipe = grid[r][c]
|
||||
|
||||
for direction in pipe_dirs[current_pipe]:
|
||||
dr, dc = deltas[direction]
|
||||
nr, nc = r + dr, c + dc
|
||||
|
||||
# Check bounds
|
||||
if 0 <= nr < m and 0 <= nc < n and (nr, nc) not in visited:
|
||||
# Check if neighbour pipe connects back
|
||||
neighbour_pipe = grid[nr][nc]
|
||||
if opposite[direction] in pipe_dirs[neighbour_pipe]:
|
||||
visited.add((nr, nc))
|
||||
queue.append((nr, nc))
|
||||
|
||||
return False
|
||||
explanation: |
|
||||
**Time Complexity:** O(m × n) — Each cell visited once.
|
||||
|
||||
**Space Complexity:** O(m × n) — Visited set and queue storage.
|
||||
|
||||
We use BFS to explore all reachable cells from the start. For each cell, we check which directions its pipe type connects to, then verify the neighbour in that direction has a compatible pipe (one that connects back). This bidirectional check ensures we only traverse valid pipe connections.
|
||||
|
||||
- approach_name: Union-Find
|
||||
is_optimal: true
|
||||
code: |
|
||||
def has_valid_path(grid: list[list[int]]) -> bool:
|
||||
m, n = len(grid), len(grid[0])
|
||||
|
||||
# Union-Find with path compression
|
||||
parent = list(range(m * n))
|
||||
|
||||
def find(x: int) -> int:
|
||||
if parent[x] != x:
|
||||
parent[x] = find(parent[x])
|
||||
return parent[x]
|
||||
|
||||
def union(x: int, y: int) -> None:
|
||||
px, py = find(x), find(y)
|
||||
if px != py:
|
||||
parent[px] = py
|
||||
|
||||
# Convert 2D to 1D index
|
||||
def idx(r: int, c: int) -> int:
|
||||
return r * n + c
|
||||
|
||||
# Pipe directions: which sides each pipe connects
|
||||
# Directions: 0=up, 1=right, 2=down, 3=left
|
||||
pipe_dirs = {
|
||||
1: {1, 3}, # left-right
|
||||
2: {0, 2}, # up-down
|
||||
3: {2, 3}, # left-down
|
||||
4: {1, 2}, # right-down
|
||||
5: {0, 3}, # left-up
|
||||
6: {0, 1}, # right-up
|
||||
}
|
||||
|
||||
# Only check right and down to avoid duplicate unions
|
||||
deltas = [(2, 1, 0), (1, 0, 1)] # (dir, dr, dc)
|
||||
opposite = {0: 2, 1: 3, 2: 0, 3: 1}
|
||||
|
||||
for r in range(m):
|
||||
for c in range(n):
|
||||
current_pipe = grid[r][c]
|
||||
|
||||
# Check right neighbour
|
||||
if c + 1 < n:
|
||||
if 1 in pipe_dirs[current_pipe]: # current opens right
|
||||
neighbour_pipe = grid[r][c + 1]
|
||||
if 3 in pipe_dirs[neighbour_pipe]: # neighbour opens left
|
||||
union(idx(r, c), idx(r, c + 1))
|
||||
|
||||
# Check down neighbour
|
||||
if r + 1 < m:
|
||||
if 2 in pipe_dirs[current_pipe]: # current opens down
|
||||
neighbour_pipe = grid[r + 1][c]
|
||||
if 0 in pipe_dirs[neighbour_pipe]: # neighbour opens up
|
||||
union(idx(r, c), idx(r + 1, c))
|
||||
|
||||
# Check if start and end are connected
|
||||
return find(idx(0, 0)) == find(idx(m - 1, n - 1))
|
||||
explanation: |
|
||||
**Time Complexity:** O(m × n × α(m × n)) ≈ O(m × n) — Union-Find operations with path compression are nearly constant time.
|
||||
|
||||
**Space Complexity:** O(m × n) — Parent array for Union-Find.
|
||||
|
||||
We iterate through each cell and union it with valid neighbours (right and down only, to avoid duplicate work). Two cells are unioned only if both pipes have compatible openings. Finally, we check if the start and end cells belong to the same connected component.
|
||||
|
||||
- approach_name: DFS with Direction Validation
|
||||
is_optimal: true
|
||||
code: |
|
||||
def has_valid_path(grid: list[list[int]]) -> bool:
|
||||
m, n = len(grid), len(grid[0])
|
||||
|
||||
# Pipe directions mapping
|
||||
pipe_dirs = {
|
||||
1: {1, 3}, # left-right
|
||||
2: {0, 2}, # up-down
|
||||
3: {2, 3}, # left-down
|
||||
4: {1, 2}, # right-down
|
||||
5: {0, 3}, # left-up
|
||||
6: {0, 1}, # right-up
|
||||
}
|
||||
|
||||
deltas = [(-1, 0), (0, 1), (1, 0), (0, -1)]
|
||||
opposite = {0: 2, 1: 3, 2: 0, 3: 1}
|
||||
visited = set()
|
||||
|
||||
def dfs(r: int, c: int) -> bool:
|
||||
# Reached destination
|
||||
if r == m - 1 and c == n - 1:
|
||||
return True
|
||||
|
||||
visited.add((r, c))
|
||||
current_pipe = grid[r][c]
|
||||
|
||||
for direction in pipe_dirs[current_pipe]:
|
||||
dr, dc = deltas[direction]
|
||||
nr, nc = r + dr, c + dc
|
||||
|
||||
if 0 <= nr < m and 0 <= nc < n and (nr, nc) not in visited:
|
||||
neighbour_pipe = grid[nr][nc]
|
||||
# Check bidirectional connection
|
||||
if opposite[direction] in pipe_dirs[neighbour_pipe]:
|
||||
if dfs(nr, nc):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
return dfs(0, 0)
|
||||
explanation: |
|
||||
**Time Complexity:** O(m × n) — Each cell visited at most once.
|
||||
|
||||
**Space Complexity:** O(m × n) — Recursion stack and visited set.
|
||||
|
||||
DFS explores paths depth-first, backtracking when hitting dead ends. The logic mirrors BFS: for each cell, we try all valid directions, verify the neighbour accepts the connection, and recursively explore. This approach is conceptually simpler but may hit Python's recursion limit on very large grids.
|
||||
@@ -0,0 +1,168 @@
|
||||
title: Check If Two String Arrays are Equivalent
|
||||
slug: check-if-two-string-arrays-are-equivalent
|
||||
difficulty: easy
|
||||
leetcode_id: 1662
|
||||
leetcode_url: https://leetcode.com/problems/check-if-two-string-arrays-are-equivalent/
|
||||
categories:
|
||||
- arrays
|
||||
- strings
|
||||
patterns:
|
||||
- two-pointers
|
||||
|
||||
description: |
|
||||
Given two string arrays `word1` and `word2`, return `true` *if the two arrays **represent** the same string, and* `false` *otherwise*.
|
||||
|
||||
A string is **represented** by an array if the array elements concatenated **in order** forms the string.
|
||||
|
||||
constraints: |
|
||||
- `1 <= word1.length, word2.length <= 10^3`
|
||||
- `1 <= word1[i].length, word2[i].length <= 10^3`
|
||||
- `1 <= sum(word1[i].length), sum(word2[i].length) <= 10^3`
|
||||
- `word1[i]` and `word2[i]` consist of lowercase letters.
|
||||
|
||||
examples:
|
||||
- input: 'word1 = ["ab", "c"], word2 = ["a", "bc"]'
|
||||
output: "true"
|
||||
explanation: 'word1 represents string "ab" + "c" -> "abc". word2 represents string "a" + "bc" -> "abc". The strings are the same, so return true.'
|
||||
- input: 'word1 = ["a", "cb"], word2 = ["ab", "c"]'
|
||||
output: "false"
|
||||
explanation: 'word1 represents "acb" while word2 represents "abc". These are different strings.'
|
||||
- input: 'word1 = ["abc", "d", "defg"], word2 = ["abcddefg"]'
|
||||
output: "true"
|
||||
explanation: 'Both arrays represent the same string "abcddefg".'
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Think of each string array as a sequence of puzzle pieces that, when placed side by side, form a complete word.
|
||||
|
||||
The question asks: do both puzzles, when assembled, spell the same word?
|
||||
|
||||
The simplest mental model is to **concatenate all the pieces** from each array and compare the resulting strings. If `"ab" + "c"` equals `"a" + "bc"`, both represent `"abc"` and we return `true`.
|
||||
|
||||
However, there's a more elegant approach: instead of building the full strings (which uses extra memory), we can compare the arrays **character by character** using pointers. Imagine two readers, each starting at the first character of the first word in their respective arrays. They move through their arrays character by character, and if at any point they see different characters, the arrays are not equivalent.
|
||||
|
||||
approach: |
|
||||
We can solve this in two ways. The straightforward approach uses string concatenation, while the optimal approach uses two pointers for O(1) space.
|
||||
|
||||
**Approach 1: String Concatenation**
|
||||
|
||||
- Join all strings in `word1` into one string
|
||||
- Join all strings in `word2` into one string
|
||||
- Compare the two resulting strings
|
||||
|
||||
|
||||
|
||||
**Approach 2: Two Pointers (Space Optimal)**
|
||||
|
||||
**Step 1: Initialise four pointers**
|
||||
|
||||
- `i`: index of the current word in `word1`
|
||||
- `j`: index of the current character within `word1[i]`
|
||||
- `k`: index of the current word in `word2`
|
||||
- `l`: index of the current character within `word2[k]`
|
||||
|
||||
|
||||
|
||||
**Step 2: Compare characters one by one**
|
||||
|
||||
- While both arrays have characters remaining:
|
||||
- Compare `word1[i][j]` with `word2[k][l]`
|
||||
- If they differ, return `false`
|
||||
- Advance `j` and `l` to the next character
|
||||
- If `j` reaches the end of `word1[i]`, move to the next word (`i += 1`, `j = 0`)
|
||||
- If `l` reaches the end of `word2[k]`, move to the next word (`k += 1`, `l = 0`)
|
||||
|
||||
|
||||
|
||||
**Step 3: Check both arrays are exhausted**
|
||||
|
||||
- After the loop, return `true` only if both arrays have been fully traversed
|
||||
- This handles cases where one array is a prefix of the other
|
||||
|
||||
common_pitfalls:
|
||||
- title: Using Extra Space Unnecessarily
|
||||
description: |
|
||||
The concatenation approach creates two new strings, each potentially up to `10^3` characters. While this works and is easy to implement, the two-pointer approach achieves the same result with O(1) extra space.
|
||||
|
||||
For this problem the constraints are small enough that either approach passes, but learning the pointer technique is valuable for larger inputs.
|
||||
wrong_approach: "Always concatenating strings without considering space"
|
||||
correct_approach: "Use two pointers to compare character by character"
|
||||
|
||||
- title: Forgetting to Check Array Exhaustion
|
||||
description: |
|
||||
When using the two-pointer approach, it's not enough to just compare matching characters. You must verify that **both arrays are fully consumed** at the end.
|
||||
|
||||
For example, if `word1 = ["abc"]` and `word2 = ["ab"]`, all characters in `word2` match, but `word1` has an extra character. Simply exiting the loop when one array ends would incorrectly return `true`.
|
||||
wrong_approach: "Returning true as soon as the loop ends"
|
||||
correct_approach: "Verify both arrays are exhausted before returning true"
|
||||
|
||||
- title: Off-by-One Errors in Pointer Movement
|
||||
description: |
|
||||
When advancing to the next word in an array, you must reset the character index to `0` and increment the word index. It's easy to forget one of these steps, causing incorrect comparisons or index out of bounds errors.
|
||||
wrong_approach: "Forgetting to reset character index when moving to next word"
|
||||
correct_approach: "Reset character index to 0 when word index increments"
|
||||
|
||||
key_takeaways:
|
||||
- "**Two-pointer technique**: When comparing sequences element by element, pointers can avoid building intermediate data structures"
|
||||
- "**Space optimisation**: The difference between O(n) and O(1) space matters for large inputs or memory-constrained environments"
|
||||
- "**Exhaustion check**: When comparing two sequences with pointers, always verify both are fully consumed at the end"
|
||||
- "**Trade-offs**: The concatenation approach is simpler to implement and debug; the pointer approach is more efficient. Choose based on constraints."
|
||||
|
||||
time_complexity: "O(n) where n is the total number of characters across both arrays. Each character is visited exactly once."
|
||||
space_complexity: "O(1) for the two-pointer approach (only using index variables). O(n) for the concatenation approach (creating two new strings)."
|
||||
|
||||
solutions:
|
||||
- approach_name: Two Pointers
|
||||
is_optimal: true
|
||||
code: |
|
||||
def array_strings_are_equal(word1: list[str], word2: list[str]) -> bool:
|
||||
# Pointers for word1: i = word index, j = char index within word
|
||||
i, j = 0, 0
|
||||
# Pointers for word2: k = word index, l = char index within word
|
||||
k, l = 0, 0
|
||||
|
||||
# Compare character by character
|
||||
while i < len(word1) and k < len(word2):
|
||||
# If current characters don't match, arrays are not equivalent
|
||||
if word1[i][j] != word2[k][l]:
|
||||
return False
|
||||
|
||||
# Move to next character in word1
|
||||
j += 1
|
||||
if j == len(word1[i]):
|
||||
# Finished current word, move to next word
|
||||
i += 1
|
||||
j = 0
|
||||
|
||||
# Move to next character in word2
|
||||
l += 1
|
||||
if l == len(word2[k]):
|
||||
# Finished current word, move to next word
|
||||
k += 1
|
||||
l = 0
|
||||
|
||||
# Both arrays must be fully traversed for equivalence
|
||||
return i == len(word1) and k == len(word2)
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Each character in both arrays is visited exactly once.
|
||||
|
||||
**Space Complexity:** O(1) — Only four integer pointers are used, regardless of input size.
|
||||
|
||||
We traverse both arrays simultaneously, comparing characters without building intermediate strings. The pointers track our position within each array at two levels: which word we're in, and which character within that word.
|
||||
|
||||
- approach_name: String Concatenation
|
||||
is_optimal: false
|
||||
code: |
|
||||
def array_strings_are_equal(word1: list[str], word2: list[str]) -> bool:
|
||||
# Join all words in each array into a single string
|
||||
str1 = ''.join(word1)
|
||||
str2 = ''.join(word2)
|
||||
|
||||
# Compare the concatenated strings
|
||||
return str1 == str2
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Joining and comparing strings both take linear time.
|
||||
|
||||
**Space Complexity:** O(n) — Two new strings are created, each up to n characters.
|
||||
|
||||
This approach is simpler and more readable. It works well for this problem's constraints but uses extra memory proportional to the total string length.
|
||||
@@ -0,0 +1,247 @@
|
||||
title: Check if Word Can Be Placed In Crossword
|
||||
slug: check-if-word-can-be-placed-in-crossword
|
||||
difficulty: medium
|
||||
leetcode_id: 2018
|
||||
leetcode_url: https://leetcode.com/problems/check-if-word-can-be-placed-in-crossword/
|
||||
categories:
|
||||
- arrays
|
||||
- strings
|
||||
patterns:
|
||||
- matrix-traversal
|
||||
|
||||
description: |
|
||||
You are given an `m x n` matrix `board`, representing the **current** state of a crossword puzzle. The crossword contains lowercase English letters (from solved words), `' '` to represent any **empty** cells, and `'#'` to represent any **blocked** cells.
|
||||
|
||||
A word can be placed **horizontally** (left to right **or** right to left) or **vertically** (top to bottom **or** bottom to top) in the board if:
|
||||
|
||||
- It does not occupy a cell containing the character `'#'`.
|
||||
- The cell each letter is placed in must either be `' '` (empty) or **match** the letter already on the `board`.
|
||||
- There must not be any empty cells `' '` or other lowercase letters **directly left or right** of the word if the word was placed **horizontally**.
|
||||
- There must not be any empty cells `' '` or other lowercase letters **directly above or below** the word if the word was placed **vertically**.
|
||||
|
||||
Given a string `word`, return `true` *if* `word` *can be placed in* `board`, *or* `false` *otherwise*.
|
||||
|
||||
constraints: |
|
||||
- `m == board.length`
|
||||
- `n == board[i].length`
|
||||
- `1 <= m * n <= 2 * 10^5`
|
||||
- `board[i][j]` will be `' '`, `'#'`, or a lowercase English letter.
|
||||
- `1 <= word.length <= max(m, n)`
|
||||
- `word` will contain only lowercase English letters.
|
||||
|
||||
examples:
|
||||
- input: 'board = [["#", " ", "#"], [" ", " ", "#"], ["#", "c", " "]], word = "abc"'
|
||||
output: "true"
|
||||
explanation: 'The word "abc" can be placed vertically (top to bottom) starting from position (0, 1).'
|
||||
- input: 'board = [[" ", "#", "a"], [" ", "#", "c"], [" ", "#", "a"]], word = "ac"'
|
||||
output: "false"
|
||||
explanation: "It is impossible to place the word because there will always be a space/letter above or below it."
|
||||
- input: 'board = [["#", " ", "#"], [" ", " ", "#"], ["#", " ", "c"]], word = "ca"'
|
||||
output: "true"
|
||||
explanation: 'The word "ca" can be placed horizontally (right to left) in the last row.'
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Think of this problem like finding a valid "slot" in a crossword puzzle where you can write your word.
|
||||
|
||||
In a real crossword puzzle, a valid slot is a sequence of empty or matching cells that is **bounded** on both ends — either by a `'#'` (blocked cell) or by the edge of the grid. The slot must be exactly the right length for your word, and any pre-filled letters must match.
|
||||
|
||||
The key insight is that we need to check **four directions** for placing the word:
|
||||
- Left to right (horizontal)
|
||||
- Right to left (horizontal, reversed)
|
||||
- Top to bottom (vertical)
|
||||
- Bottom to top (vertical, reversed)
|
||||
|
||||
Rather than searching the entire grid for every possible starting position, we can systematically find all valid "slots" in each row and column. A slot is defined as a maximal sequence of non-`'#'` cells (i.e., cells that are either empty or contain a letter).
|
||||
|
||||
For each slot we find, we check if the word (or its reverse) fits perfectly: the slot length must equal the word length, and each cell must either be empty or match the corresponding letter in the word.
|
||||
|
||||
approach: |
|
||||
We solve this by checking rows (horizontal placement) and columns (vertical placement) separately.
|
||||
|
||||
**Step 1: Define a helper function to check slots in a line**
|
||||
|
||||
- Given a line (a row or column), split it by `'#'` to get all candidate slots
|
||||
- Each slot is a maximal sequence of non-blocked cells
|
||||
- For each slot, check if the word fits (forward or backward)
|
||||
|
||||
|
||||
|
||||
**Step 2: Check if word fits in a slot**
|
||||
|
||||
- The slot length must equal the word length
|
||||
- For each position, the cell must be `' '` (empty) or match the word's letter
|
||||
- Check both the word and its reverse to handle bidirectional placement
|
||||
|
||||
|
||||
|
||||
**Step 3: Check all rows for horizontal placement**
|
||||
|
||||
- For each row, extract the cells and find all slots
|
||||
- Test if the word can fit in any slot (left-to-right or right-to-left)
|
||||
|
||||
|
||||
|
||||
**Step 4: Check all columns for vertical placement**
|
||||
|
||||
- For each column, extract the cells vertically and find all slots
|
||||
- Test if the word can fit in any slot (top-to-bottom or bottom-to-top)
|
||||
|
||||
|
||||
|
||||
**Step 5: Return result**
|
||||
|
||||
- Return `true` if any valid placement is found, `false` otherwise
|
||||
|
||||
common_pitfalls:
|
||||
- title: Forgetting Bidirectional Placement
|
||||
description: |
|
||||
The word can be placed in **four** directions, not just two. A common mistake is only checking left-to-right and top-to-bottom.
|
||||
|
||||
For example, if the word is "cat" and you only check forward placement, you'll miss valid positions where "tac" would fit (meaning "cat" can be placed right-to-left).
|
||||
|
||||
Solution: Check both the word and its reverse, or equivalently, check the slot from both ends.
|
||||
wrong_approach: "Only checking forward direction"
|
||||
correct_approach: "Check both word and reversed word for each slot"
|
||||
|
||||
- title: Not Enforcing Slot Boundaries
|
||||
description: |
|
||||
A valid placement requires the word to occupy a **complete slot** — it must be bounded by `'#'` or the grid edge on both ends.
|
||||
|
||||
For example, if you have a row `[' ', ' ', ' ', '#']` and word = "ab", you cannot place "ab" at positions 0-1 because position 2 is still part of the same open slot. The slot has length 3, not 2.
|
||||
|
||||
This means you can't just find any two consecutive empty cells; you must find a slot whose total length equals the word length.
|
||||
wrong_approach: "Looking for any substring of matching length"
|
||||
correct_approach: "Split by '#' to find complete slots, then check exact length match"
|
||||
|
||||
- title: Ignoring Pre-filled Letters
|
||||
description: |
|
||||
Some cells may already contain letters from other words in the crossword. You must check that these letters **match** the corresponding letter in your word.
|
||||
|
||||
A common bug is treating all non-`'#'` cells as empty and overwriting them.
|
||||
wrong_approach: "Treating all non-blocked cells as empty"
|
||||
correct_approach: "Check that existing letters match or cell is empty"
|
||||
|
||||
key_takeaways:
|
||||
- "**Matrix traversal with constraints**: When checking placements in a grid, consider all valid directions and boundary conditions"
|
||||
- "**Slot-based thinking**: Instead of checking every starting position, identify valid slots first (bounded sequences), then verify if the word fits"
|
||||
- "**Bidirectional checking**: Many grid problems require considering both forward and backward directions — factor this into your solution"
|
||||
- "**Similar problems**: This pattern applies to other crossword/word-search problems like Word Search, Word Search II, and word-placement puzzles"
|
||||
|
||||
time_complexity: "O(m * n). We iterate through each cell of the grid at most a constant number of times — once when processing rows and once when processing columns."
|
||||
space_complexity: "O(max(m, n)). We may store a row or column temporarily when checking slots, which has at most `max(m, n)` elements."
|
||||
|
||||
solutions:
|
||||
- approach_name: Slot-Based Matching
|
||||
is_optimal: true
|
||||
code: |
|
||||
def place_word_in_crossword(board: list[list[str]], word: str) -> bool:
|
||||
m, n = len(board), len(board[0])
|
||||
|
||||
def can_place(slot: list[str], word: str) -> bool:
|
||||
"""Check if word can be placed in slot (forward or backward)."""
|
||||
if len(slot) != len(word):
|
||||
return False
|
||||
# Check forward placement
|
||||
forward = all(
|
||||
cell == ' ' or cell == ch
|
||||
for cell, ch in zip(slot, word)
|
||||
)
|
||||
# Check backward placement
|
||||
backward = all(
|
||||
cell == ' ' or cell == ch
|
||||
for cell, ch in zip(slot, reversed(word))
|
||||
)
|
||||
return forward or backward
|
||||
|
||||
def check_line(line: list[str]) -> bool:
|
||||
"""Check all slots in a line (row or column)."""
|
||||
# Split line by '#' to get all slots
|
||||
slot = []
|
||||
for cell in line:
|
||||
if cell == '#':
|
||||
# End of current slot, check it
|
||||
if can_place(slot, word):
|
||||
return True
|
||||
slot = []
|
||||
else:
|
||||
slot.append(cell)
|
||||
# Check final slot after last '#' (or if no '#' at all)
|
||||
return can_place(slot, word)
|
||||
|
||||
# Check all rows (horizontal placement)
|
||||
for row in board:
|
||||
if check_line(row):
|
||||
return True
|
||||
|
||||
# Check all columns (vertical placement)
|
||||
for col in range(n):
|
||||
column = [board[row][col] for row in range(m)]
|
||||
if check_line(column):
|
||||
return True
|
||||
|
||||
return False
|
||||
explanation: |
|
||||
**Time Complexity:** O(m * n) — We process each cell twice (once for rows, once for columns), and the word matching is O(word length) which is bounded by max(m, n).
|
||||
|
||||
**Space Complexity:** O(max(m, n)) — We store column data temporarily and maintain slots during iteration.
|
||||
|
||||
This approach elegantly handles all four directions by recognizing that checking a slot forward and backward covers both horizontal directions (for rows) and both vertical directions (for columns).
|
||||
|
||||
- approach_name: Direct Position Checking
|
||||
is_optimal: false
|
||||
code: |
|
||||
def place_word_in_crossword(board: list[list[str]], word: str) -> bool:
|
||||
m, n = len(board), len(board[0])
|
||||
k = len(word)
|
||||
|
||||
def is_valid_start(r: int, c: int, dr: int, dc: int) -> bool:
|
||||
"""Check if position (r, c) is a valid start for direction (dr, dc)."""
|
||||
# Previous cell must be '#' or out of bounds
|
||||
pr, pc = r - dr, c - dc
|
||||
if 0 <= pr < m and 0 <= pc < n and board[pr][pc] != '#':
|
||||
return False
|
||||
return True
|
||||
|
||||
def is_valid_end(r: int, c: int, dr: int, dc: int) -> bool:
|
||||
"""Check if position after word is valid (boundary or '#')."""
|
||||
er, ec = r + dr * k, c + dc * k
|
||||
if 0 <= er < m and 0 <= ec < n and board[er][ec] != '#':
|
||||
return False
|
||||
return True
|
||||
|
||||
def can_place_at(r: int, c: int, dr: int, dc: int) -> bool:
|
||||
"""Check if word can be placed starting at (r, c) in direction (dr, dc)."""
|
||||
if not is_valid_start(r, c, dr, dc):
|
||||
return False
|
||||
if not is_valid_end(r, c, dr, dc):
|
||||
return False
|
||||
|
||||
# Check each letter of the word
|
||||
for i in range(k):
|
||||
nr, nc = r + dr * i, c + dc * i
|
||||
# Must be within bounds
|
||||
if not (0 <= nr < m and 0 <= nc < n):
|
||||
return False
|
||||
cell = board[nr][nc]
|
||||
# Cell must be empty or match the letter
|
||||
if cell != ' ' and cell != word[i]:
|
||||
return False
|
||||
return True
|
||||
|
||||
# Four directions: right, left, down, up
|
||||
directions = [(0, 1), (0, -1), (1, 0), (-1, 0)]
|
||||
|
||||
# Try every starting position and direction
|
||||
for r in range(m):
|
||||
for c in range(n):
|
||||
for dr, dc in directions:
|
||||
if can_place_at(r, c, dr, dc):
|
||||
return True
|
||||
return False
|
||||
explanation: |
|
||||
**Time Complexity:** O(m * n * k) — For each cell, we check 4 directions, and each check takes O(k) where k is the word length.
|
||||
|
||||
**Space Complexity:** O(1) — Only uses constant extra space.
|
||||
|
||||
This brute-force approach checks every possible starting position and direction. While correct, it's less elegant than the slot-based approach because it explicitly handles boundary conditions at every step. The slot-based approach naturally handles boundaries by splitting on `'#'`.
|
||||
@@ -0,0 +1,162 @@
|
||||
title: Check if Word Equals Summation of Two Words
|
||||
slug: check-if-word-equals-summation-of-two-words
|
||||
difficulty: easy
|
||||
leetcode_id: 1880
|
||||
leetcode_url: https://leetcode.com/problems/check-if-word-equals-summation-of-two-words/
|
||||
categories:
|
||||
- strings
|
||||
patterns:
|
||||
- greedy
|
||||
|
||||
description: |
|
||||
The **letter value** of a letter is its position in the alphabet **starting from 0** (i.e. `'a' -> 0`, `'b' -> 1`, `'c' -> 2`, etc.).
|
||||
|
||||
The **numerical value** of some string of lowercase English letters `s` is the **concatenation** of the **letter values** of each letter in `s`, which is then **converted** into an integer.
|
||||
|
||||
For example, if `s = "acb"`, we concatenate each letter's letter value, resulting in `"021"`. After converting it, we get `21`.
|
||||
|
||||
You are given three strings `firstWord`, `secondWord`, and `targetWord`, each consisting of lowercase English letters `'a'` through `'j'` **inclusive**.
|
||||
|
||||
Return `true` *if the **summation** of the **numerical values** of* `firstWord` *and* `secondWord` *equals the **numerical value** of* `targetWord`, *or* `false` *otherwise*.
|
||||
|
||||
constraints: |
|
||||
- `1 <= firstWord.length, secondWord.length, targetWord.length <= 8`
|
||||
- `firstWord`, `secondWord`, and `targetWord` consist of lowercase English letters from `'a'` to `'j'` **inclusive**
|
||||
|
||||
examples:
|
||||
- input: 'firstWord = "acb", secondWord = "cba", targetWord = "cdb"'
|
||||
output: "true"
|
||||
explanation: 'The numerical value of firstWord is "acb" -> "021" -> 21. The numerical value of secondWord is "cba" -> "210" -> 210. The numerical value of targetWord is "cdb" -> "231" -> 231. We return true because 21 + 210 == 231.'
|
||||
- input: 'firstWord = "aaa", secondWord = "a", targetWord = "aab"'
|
||||
output: "false"
|
||||
explanation: 'The numerical value of firstWord is "aaa" -> "000" -> 0. The numerical value of secondWord is "a" -> "0" -> 0. The numerical value of targetWord is "aab" -> "001" -> 1. We return false because 0 + 0 != 1.'
|
||||
- input: 'firstWord = "aaa", secondWord = "a", targetWord = "aaaa"'
|
||||
output: "true"
|
||||
explanation: 'The numerical value of firstWord is "aaa" -> "000" -> 0. The numerical value of secondWord is "a" -> "0" -> 0. The numerical value of targetWord is "aaaa" -> "0000" -> 0. We return true because 0 + 0 == 0.'
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Think of this problem like a secret code where each letter maps to a digit. The letters `'a'` through `'j'` correspond to digits `0` through `9`. When you string these digits together, you form a number — just like how the digits `2`, `1`, `0` form the number `210`.
|
||||
|
||||
The key insight is that we're not adding up individual letter values; we're treating them as **positional digits** in a decimal number. The letter `'c'` in `"cba"` isn't contributing `2` to a sum — it's contributing `200` because it's in the hundreds place.
|
||||
|
||||
Once you recognise this, the problem becomes straightforward: convert each word to its numerical value, then check if the sum of the first two equals the third.
|
||||
|
||||
The constraint that letters are limited to `'a'` through `'j'` ensures we only deal with single digits (`0-9`), making the conversion clean and unambiguous.
|
||||
|
||||
approach: |
|
||||
We solve this by converting each word to its numerical value and comparing:
|
||||
|
||||
**Step 1: Create a helper function to convert a word to its numerical value**
|
||||
|
||||
- Iterate through each character in the word
|
||||
- For each character, calculate its digit value: `ord(char) - ord('a')` gives `0` for `'a'`, `1` for `'b'`, etc.
|
||||
- Build the number by treating digits positionally: `result = result * 10 + digit`
|
||||
|
||||
|
||||
|
||||
**Step 2: Convert all three words**
|
||||
|
||||
- Apply the conversion function to `firstWord`, `secondWord`, and `targetWord`
|
||||
- This gives us three integers to compare
|
||||
|
||||
|
||||
|
||||
**Step 3: Return the comparison**
|
||||
|
||||
- Return `True` if `value(firstWord) + value(secondWord) == value(targetWord)`
|
||||
- Return `False` otherwise
|
||||
|
||||
|
||||
|
||||
The positional value calculation (`result * 10 + digit`) works because each new digit shifts existing digits left (multiplying by 10) before adding the new digit in the ones place.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Adding Letter Values Instead of Concatenating
|
||||
description: |
|
||||
A common mistake is to add up the individual letter values rather than treating them as positional digits.
|
||||
|
||||
For example, with `"acb"`:
|
||||
- **Wrong**: `0 + 2 + 1 = 3`
|
||||
- **Correct**: `"021"` → `21`
|
||||
|
||||
The problem explicitly states we **concatenate** letter values to form a string, then convert that string to an integer. The order and position of letters matters!
|
||||
wrong_approach: "Sum individual letter values"
|
||||
correct_approach: "Concatenate digits positionally"
|
||||
|
||||
- title: String Concatenation Performance
|
||||
description: |
|
||||
While you could build the number as a string (concatenating digit characters) and then convert with `int()`, this is less efficient than the mathematical approach.
|
||||
|
||||
Building numbers mathematically with `result = result * 10 + digit` avoids string allocation and is more performant, though with the small input constraints (`length <= 8`) both approaches work fine.
|
||||
wrong_approach: "String concatenation then int()"
|
||||
correct_approach: "Mathematical digit accumulation"
|
||||
|
||||
- title: Forgetting Leading Zeros
|
||||
description: |
|
||||
Words like `"aaa"` convert to `"000"` which equals `0`, not `000`. This is handled automatically when we build the number mathematically or when Python's `int()` parses the string.
|
||||
|
||||
The third example tests this edge case explicitly — make sure your solution handles words that convert to zero correctly.
|
||||
|
||||
key_takeaways:
|
||||
- "**Character-to-digit mapping**: `ord(char) - ord('a')` is a common pattern for converting lowercase letters to numeric values"
|
||||
- "**Positional number building**: `result = result * 10 + digit` builds a number digit-by-digit from left to right"
|
||||
- "**Read carefully**: The problem describes concatenation, not addition — understanding the problem statement precisely is crucial"
|
||||
- "**Constraint awareness**: Letters limited to `'a'`-`'j'` ensures single digits, making the conversion straightforward"
|
||||
|
||||
time_complexity: "O(n) where n is the total length of all three strings. We traverse each string exactly once to compute its numerical value."
|
||||
space_complexity: "O(1). We only use a constant number of integer variables regardless of input size."
|
||||
|
||||
solutions:
|
||||
- approach_name: Mathematical Conversion
|
||||
is_optimal: true
|
||||
code: |
|
||||
def is_sum_equal(first_word: str, second_word: str, target_word: str) -> bool:
|
||||
def word_to_value(word: str) -> int:
|
||||
"""Convert a word to its numerical value."""
|
||||
result = 0
|
||||
for char in word:
|
||||
# Each letter's value is its position: 'a'=0, 'b'=1, etc.
|
||||
digit = ord(char) - ord('a')
|
||||
# Shift existing digits left and add new digit
|
||||
result = result * 10 + digit
|
||||
return result
|
||||
|
||||
# Convert all words and check if sum equals target
|
||||
first_value = word_to_value(first_word)
|
||||
second_value = word_to_value(second_word)
|
||||
target_value = word_to_value(target_word)
|
||||
|
||||
return first_value + second_value == target_value
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — We iterate through each character once.
|
||||
|
||||
**Space Complexity:** O(1) — Only integer variables used.
|
||||
|
||||
We convert each word to a number by processing characters left to right, treating each letter as a digit. The formula `result * 10 + digit` shifts existing digits left and places the new digit in the ones position.
|
||||
|
||||
- approach_name: String Concatenation
|
||||
is_optimal: false
|
||||
code: |
|
||||
def is_sum_equal(first_word: str, second_word: str, target_word: str) -> bool:
|
||||
def word_to_value(word: str) -> int:
|
||||
"""Convert a word to its numerical value using string concatenation."""
|
||||
# Build string of digits
|
||||
digit_string = ""
|
||||
for char in word:
|
||||
digit = ord(char) - ord('a')
|
||||
digit_string += str(digit)
|
||||
# Convert concatenated string to integer
|
||||
return int(digit_string)
|
||||
|
||||
first_value = word_to_value(first_word)
|
||||
second_value = word_to_value(second_word)
|
||||
target_value = word_to_value(target_word)
|
||||
|
||||
return first_value + second_value == target_value
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Iterating through characters, though string concatenation has hidden costs.
|
||||
|
||||
**Space Complexity:** O(n) — We create intermediate strings.
|
||||
|
||||
This approach explicitly builds the digit string before converting to an integer. While conceptually matching the problem description, it's less efficient due to string allocation. With the small input size constraint (max 8 characters), the difference is negligible in practice.
|
||||
@@ -0,0 +1,160 @@
|
||||
title: Check If Word Is Valid After Substitutions
|
||||
slug: check-if-word-is-valid-after-substitutions
|
||||
difficulty: medium
|
||||
leetcode_id: 1003
|
||||
leetcode_url: https://leetcode.com/problems/check-if-word-is-valid-after-substitutions/
|
||||
categories:
|
||||
- strings
|
||||
- stack
|
||||
patterns:
|
||||
- monotonic-stack
|
||||
|
||||
description: |
|
||||
Given a string `s`, determine if it is **valid**.
|
||||
|
||||
A string `s` is **valid** if, starting with an empty string `t = ""`, you can **transform** `t` **into** `s` after performing the following operation **any number of times**:
|
||||
|
||||
- Insert the string `"abc"` into any position in `t`. More formally, `t` becomes `t_left + "abc" + t_right`, where `t == t_left + t_right`. Note that `t_left` and `t_right` may be **empty**.
|
||||
|
||||
Return `true` *if* `s` *is a valid string, otherwise, return* `false`.
|
||||
|
||||
constraints: |
|
||||
- `1 <= s.length <= 2 * 10^4`
|
||||
- `s` consists of letters `'a'`, `'b'`, and `'c'` only
|
||||
|
||||
examples:
|
||||
- input: 's = "aabcbc"'
|
||||
output: "true"
|
||||
explanation: '"" -> "abc" -> "aabcbc". We can insert "abc" at position 1 (after the first "a"), producing the valid string.'
|
||||
- input: 's = "abcabcababcc"'
|
||||
output: "true"
|
||||
explanation: '"" -> "abc" -> "abcabc" -> "abcabcabc" -> "abcabcababcc". Multiple insertions at different positions produce this valid string.'
|
||||
- input: 's = "abccba"'
|
||||
output: "false"
|
||||
explanation: "It is impossible to produce \"abccba\" using only insertions of \"abc\". The sequence \"cba\" cannot be formed by any valid insertion."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Think of this problem like **matching parentheses**, but instead of `(` and `)`, we're matching the sequence `a`, `b`, `c`.
|
||||
|
||||
Imagine you're reading the string character by character. When you see an `a`, it's like opening a new "bracket". When you see a `b`, it must follow an `a`. When you see a `c`, it must complete a valid `abc` triplet.
|
||||
|
||||
The key insight is that whenever we encounter `c`, the two characters immediately before it (in our "pending" sequence) must be `a` and `b` in that order. If they are, we've found a complete `abc` and can "remove" it, just like matching parentheses.
|
||||
|
||||
This is the **reverse operation** of the problem: instead of inserting `abc`, we *remove* every `abc` we find. If the string was built from valid insertions, we should be able to remove all characters and end with an empty string.
|
||||
|
||||
approach: |
|
||||
We solve this using a **Stack-Based Approach**:
|
||||
|
||||
**Step 1: Initialise an empty stack**
|
||||
|
||||
- `stack`: An empty list to track characters we've seen but haven't matched yet
|
||||
|
||||
|
||||
|
||||
**Step 2: Process each character**
|
||||
|
||||
- For each character `c` in the string:
|
||||
- If `c == 'c'`: Check if the stack ends with `['a', 'b']`
|
||||
- If yes, pop both `a` and `b` — we've matched a complete `abc`
|
||||
- If no, return `False` — invalid sequence
|
||||
- Otherwise: Push the character onto the stack
|
||||
|
||||
|
||||
|
||||
**Step 3: Check final state**
|
||||
|
||||
- After processing all characters, return `True` if the stack is empty
|
||||
- An empty stack means all characters formed complete `abc` triplets
|
||||
|
||||
|
||||
|
||||
**Why this works:** Every valid string is built by inserting `abc` into previous valid strings. By "unwinding" these insertions (removing `abc` whenever we complete one), we reverse the construction process. A valid string reduces to empty; an invalid one leaves residue.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Using String Replacement
|
||||
description: |
|
||||
A tempting approach is to repeatedly use `s.replace("abc", "")` until no more replacements occur, then check if the string is empty.
|
||||
|
||||
While this is correct, it's **inefficient**. Each replacement creates a new string and re-scans, leading to **O(n^2)** time complexity in the worst case. For `n = 2 * 10^4`, this risks TLE.
|
||||
|
||||
The stack approach processes each character exactly once for **O(n)** time.
|
||||
wrong_approach: "Repeated string replacement"
|
||||
correct_approach: "Stack-based single pass"
|
||||
|
||||
- title: Not Checking Stack Size Before Popping
|
||||
description: |
|
||||
When you see a `c`, you need to verify the stack has at least two elements (`a` and `b`) before popping.
|
||||
|
||||
For example, with input `"c"` or `"bc"`, the stack doesn't have enough characters. Attempting to pop from an insufficient stack causes an error or incorrect result.
|
||||
|
||||
Always check `len(stack) >= 2` before accessing `stack[-1]` and `stack[-2]`.
|
||||
wrong_approach: "Pop without checking stack size"
|
||||
correct_approach: "Verify stack has at least 2 elements before checking for 'ab'"
|
||||
|
||||
- title: Forgetting Order Matters
|
||||
description: |
|
||||
The sequence must be exactly `a`, then `b`, then `c`. Checking only that the stack contains `a` and `b` somewhere isn't enough.
|
||||
|
||||
For input `"bac"`, a naive check might pass, but `bac` cannot be formed by inserting `abc`. The stack must end with `a` at position `-2` and `b` at position `-1` specifically.
|
||||
wrong_approach: "Check if 'a' and 'b' exist anywhere in stack"
|
||||
correct_approach: "Check stack[-2] == 'a' and stack[-1] == 'b'"
|
||||
|
||||
key_takeaways:
|
||||
- "**Stack for sequence matching**: When validating nested or sequential patterns, stacks let you track 'pending' elements and match them when completed"
|
||||
- "**Reverse the operation**: Instead of simulating insertion, simulate *removal* — valid constructions can be fully deconstructed"
|
||||
- "**Character-by-character processing**: Building or unwinding strings incrementally often leads to linear-time solutions"
|
||||
- "**Similar problems**: This pattern applies to valid parentheses, HTML tag matching, and other nested structure validation"
|
||||
|
||||
time_complexity: "O(n). Each character is pushed onto the stack at most once and popped at most once, giving us linear time."
|
||||
space_complexity: "O(n). In the worst case (e.g., `\"aaa...\"` with only `a`s), the stack stores all characters."
|
||||
|
||||
solutions:
|
||||
- approach_name: Stack
|
||||
is_optimal: true
|
||||
code: |
|
||||
def is_valid(s: str) -> bool:
|
||||
stack = []
|
||||
|
||||
for char in s:
|
||||
if char == 'c':
|
||||
# Check if we can complete an "abc" triplet
|
||||
if len(stack) >= 2 and stack[-2] == 'a' and stack[-1] == 'b':
|
||||
# Remove the 'a' and 'b' that preceded this 'c'
|
||||
stack.pop()
|
||||
stack.pop()
|
||||
else:
|
||||
# Invalid: 'c' without proper 'ab' prefix
|
||||
return False
|
||||
else:
|
||||
# Push 'a' or 'b' onto the stack
|
||||
stack.append(char)
|
||||
|
||||
# Valid only if all characters formed complete "abc" triplets
|
||||
return len(stack) == 0
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Single pass through the string, with O(1) stack operations per character.
|
||||
|
||||
**Space Complexity:** O(n) — Stack may hold up to n characters in the worst case.
|
||||
|
||||
We process characters left to right. When we see `c`, we check if the top of the stack is `ab`. If so, we pop both and effectively "remove" a complete `abc`. If not, the string is invalid. At the end, an empty stack means every character was part of a valid `abc` triplet.
|
||||
|
||||
- approach_name: String Replacement
|
||||
is_optimal: false
|
||||
code: |
|
||||
def is_valid(s: str) -> bool:
|
||||
# Keep removing "abc" until no more can be removed
|
||||
prev_len = -1
|
||||
|
||||
while len(s) != prev_len:
|
||||
prev_len = len(s)
|
||||
s = s.replace("abc", "")
|
||||
|
||||
# Valid if everything was removed
|
||||
return len(s) == 0
|
||||
explanation: |
|
||||
**Time Complexity:** O(n^2) — Each replacement takes O(n), and we may need O(n) replacements.
|
||||
|
||||
**Space Complexity:** O(n) — Each replacement creates a new string.
|
||||
|
||||
This approach repeatedly removes all occurrences of `"abc"` until the string stops changing. While correct and intuitive, it's slower than the stack approach due to repeated string creation and scanning. Useful for understanding the problem but not optimal for large inputs.
|
||||
161
backend/data/questions/check-if-word-occurs-as-prefix.yaml
Normal file
161
backend/data/questions/check-if-word-occurs-as-prefix.yaml
Normal file
@@ -0,0 +1,161 @@
|
||||
title: Check If a Word Occurs As a Prefix of Any Word in a Sentence
|
||||
slug: check-if-word-occurs-as-prefix
|
||||
difficulty: easy
|
||||
leetcode_id: 1455
|
||||
leetcode_url: https://leetcode.com/problems/check-if-a-word-occurs-as-a-prefix-of-any-word-in-a-sentence/
|
||||
categories:
|
||||
- strings
|
||||
patterns:
|
||||
- two-pointers
|
||||
|
||||
description: |
|
||||
Given a `sentence` that consists of some words separated by a **single space**, and a `searchWord`, check if `searchWord` is a prefix of any word in `sentence`.
|
||||
|
||||
Return *the index of the word in* `sentence` *(**1-indexed**) where* `searchWord` *is a prefix of this word*. If `searchWord` is a prefix of more than one word, return the index of the first word **(minimum index)**. If there is no such word return `-1`.
|
||||
|
||||
A **prefix** of a string `s` is any leading contiguous substring of `s`.
|
||||
|
||||
constraints: |
|
||||
- `1 <= sentence.length <= 100`
|
||||
- `1 <= searchWord.length <= 10`
|
||||
- `sentence` consists of lowercase English letters and spaces.
|
||||
- `searchWord` consists of lowercase English letters.
|
||||
|
||||
examples:
|
||||
- input: 'sentence = "i love eating burger", searchWord = "burg"'
|
||||
output: "4"
|
||||
explanation: '"burg" is prefix of "burger" which is the 4th word in the sentence.'
|
||||
- input: 'sentence = "this problem is an easy problem", searchWord = "pro"'
|
||||
output: "2"
|
||||
explanation: '"pro" is prefix of "problem" which is the 2nd and the 6th word in the sentence, but we return 2 as it''s the minimal index.'
|
||||
- input: 'sentence = "i am tired", searchWord = "you"'
|
||||
output: "-1"
|
||||
explanation: '"you" is not a prefix of any word in the sentence.'
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Think of the sentence as a sequence of words laid out in a row, like books on a shelf. You're looking for the first book whose title *starts with* a specific set of letters.
|
||||
|
||||
The key insight is that we don't need any fancy string matching algorithms here. The problem is straightforward: split the sentence into individual words, then check each word one by one to see if it begins with `searchWord`.
|
||||
|
||||
Since we want the **first** matching word, we iterate through the words in order and return immediately when we find a match. This "early return" pattern is efficient because we stop as soon as we find what we're looking for.
|
||||
|
||||
The prefix check itself is simple: a word has `searchWord` as a prefix if the first `len(searchWord)` characters of the word exactly match `searchWord`. Most languages provide a built-in `startswith()` or similar method for this.
|
||||
|
||||
approach: |
|
||||
We solve this using a **Linear Scan with Prefix Check**:
|
||||
|
||||
**Step 1: Split the sentence into words**
|
||||
|
||||
- Use the space character as the delimiter to break the sentence into a list of words
|
||||
- This gives us direct access to each word by index
|
||||
|
||||
|
||||
|
||||
**Step 2: Iterate through words with their indices**
|
||||
|
||||
- For each word, check if it starts with `searchWord`
|
||||
- Python's `str.startswith()` method handles the prefix comparison efficiently
|
||||
- Remember the problem uses **1-indexed** positions, so we need to add 1 to the 0-based index
|
||||
|
||||
|
||||
|
||||
**Step 3: Return the result**
|
||||
|
||||
- If a word matches, return its 1-indexed position immediately
|
||||
- If no word matches after checking all, return `-1`
|
||||
|
||||
|
||||
|
||||
This approach works because we process words in order, guaranteeing we find the minimum index first.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Off-by-One Error with 1-Indexing
|
||||
description: |
|
||||
The problem explicitly states that word positions are **1-indexed**, not 0-indexed. Many programming languages use 0-based indexing by default.
|
||||
|
||||
If you iterate with a loop like `for i in range(len(words))` and return `i` directly, you'll be off by one. The first word should return `1`, not `0`.
|
||||
|
||||
Always add 1 to convert from 0-indexed to 1-indexed, or use `enumerate(words, start=1)`.
|
||||
wrong_approach: "Returning 0-indexed position directly"
|
||||
correct_approach: "Add 1 to convert to 1-indexed position"
|
||||
|
||||
- title: Manual Prefix Checking Errors
|
||||
description: |
|
||||
If you implement prefix checking manually instead of using built-in methods, you might forget to check if the word is long enough.
|
||||
|
||||
For example, checking `word[:len(searchWord)] == searchWord` works, but manually comparing character-by-character without bounds checking could cause index out of range errors when the word is shorter than the search word.
|
||||
|
||||
Using `startswith()` handles all edge cases automatically.
|
||||
wrong_approach: "Manual character comparison without length check"
|
||||
correct_approach: "Use built-in startswith() or slice with length validation"
|
||||
|
||||
- title: Forgetting Empty String Edge Cases
|
||||
description: |
|
||||
While the constraints guarantee non-empty inputs, in a real interview you might encounter edge cases like empty sentences or empty search words.
|
||||
|
||||
An empty `searchWord` is technically a prefix of every word. Be prepared to discuss how you'd handle these cases if the constraints were relaxed.
|
||||
|
||||
key_takeaways:
|
||||
- "**Use built-in methods**: `startswith()` is cleaner and handles edge cases better than manual prefix checking"
|
||||
- "**Early return pattern**: Stop iterating as soon as you find the answer to avoid unnecessary work"
|
||||
- "**Watch for indexing conventions**: Problems often specify 1-indexed results when natural language is involved"
|
||||
- "**String splitting is your friend**: Converting a sentence to a word list simplifies iteration and access"
|
||||
|
||||
time_complexity: "O(n * m). We iterate through each character of the sentence once during split (O(n)), then for each word we may compare up to `m` characters where `m` is the length of `searchWord`. In the worst case, we check all words."
|
||||
space_complexity: "O(n). We store the list of words after splitting, which in the worst case contains all characters from the original sentence."
|
||||
|
||||
solutions:
|
||||
- approach_name: Linear Scan with startswith()
|
||||
is_optimal: true
|
||||
code: |
|
||||
def is_prefix_of_word(sentence: str, search_word: str) -> int:
|
||||
# Split sentence into list of words
|
||||
words = sentence.split()
|
||||
|
||||
# Check each word (enumerate with start=1 for 1-indexed result)
|
||||
for i, word in enumerate(words, start=1):
|
||||
# If this word starts with searchWord, return its position
|
||||
if word.startswith(search_word):
|
||||
return i
|
||||
|
||||
# No word had the prefix
|
||||
return -1
|
||||
explanation: |
|
||||
**Time Complexity:** O(n * m) — Where n is the sentence length and m is the search word length. Split is O(n), and each startswith check is O(m) in the worst case.
|
||||
|
||||
**Space Complexity:** O(n) — The words list stores all characters from the sentence.
|
||||
|
||||
This is the cleanest and most Pythonic solution. Using `enumerate` with `start=1` handles the 1-indexing requirement elegantly.
|
||||
|
||||
- approach_name: Manual Iteration Without Split
|
||||
is_optimal: false
|
||||
code: |
|
||||
def is_prefix_of_word(sentence: str, search_word: str) -> int:
|
||||
word_index = 1 # 1-indexed word counter
|
||||
word_start = 0 # Start position of current word
|
||||
|
||||
i = 0
|
||||
while i <= len(sentence):
|
||||
# End of word: either space or end of sentence
|
||||
if i == len(sentence) or sentence[i] == ' ':
|
||||
# Extract current word
|
||||
word = sentence[word_start:i]
|
||||
|
||||
# Check if searchWord is a prefix
|
||||
if word.startswith(search_word):
|
||||
return word_index
|
||||
|
||||
# Move to next word
|
||||
word_index += 1
|
||||
word_start = i + 1
|
||||
|
||||
i += 1
|
||||
|
||||
return -1
|
||||
explanation: |
|
||||
**Time Complexity:** O(n * m) — Same as the split approach.
|
||||
|
||||
**Space Complexity:** O(w) — Where w is the length of the longest word (for the substring extraction).
|
||||
|
||||
This approach avoids creating a list of all words upfront, potentially saving memory for very long sentences. However, it's more complex and the savings are negligible given the problem constraints.
|
||||
189
backend/data/questions/check-knight-tour-configuration.yaml
Normal file
189
backend/data/questions/check-knight-tour-configuration.yaml
Normal file
@@ -0,0 +1,189 @@
|
||||
title: Check Knight Tour Configuration
|
||||
slug: check-knight-tour-configuration
|
||||
difficulty: medium
|
||||
leetcode_id: 2596
|
||||
leetcode_url: https://leetcode.com/problems/check-knight-tour-configuration/
|
||||
categories:
|
||||
- arrays
|
||||
- graphs
|
||||
patterns:
|
||||
- matrix-traversal
|
||||
|
||||
description: |
|
||||
There is a knight on an `n x n` chessboard. In a valid configuration, the knight starts **at the top-left cell** of the board and visits every cell on the board **exactly once**.
|
||||
|
||||
You are given an `n x n` integer matrix `grid` consisting of distinct integers from the range `[0, n * n - 1]` where `grid[row][col]` indicates that the cell `(row, col)` is the `grid[row][col]`<sup>th</sup> cell that the knight visited. The moves are **0-indexed**.
|
||||
|
||||
Return `true` *if* `grid` *represents a valid configuration of the knight's movements or* `false` *otherwise*.
|
||||
|
||||
**Note** that a valid knight move consists of moving two squares vertically and one square horizontally, or two squares horizontally and one square vertically.
|
||||
|
||||
constraints: |
|
||||
- `n == grid.length == grid[i].length`
|
||||
- `3 <= n <= 7`
|
||||
- `0 <= grid[row][col] < n * n`
|
||||
- All integers in `grid` are **unique**
|
||||
|
||||
examples:
|
||||
- input: "grid = [[0,11,16,5,20],[17,4,19,10,15],[12,1,8,21,6],[3,18,23,14,9],[24,13,2,7,22]]"
|
||||
output: "true"
|
||||
explanation: "The grid represents a valid knight's tour. Starting from position (0,0) with value 0, the knight makes valid L-shaped moves to visit every cell exactly once."
|
||||
- input: "grid = [[0,3,6],[5,8,1],[2,7,4]]"
|
||||
output: "false"
|
||||
explanation: "The 8th move of the knight is not valid considering its position after the 7th move. The knight cannot reach the cell marked 8 from the cell marked 7 using a valid knight move."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Think of this problem as verifying a recorded sequence of moves rather than finding one.
|
||||
|
||||
The grid tells us the **order** in which each cell was visited. Cell with value `0` was visited first, cell with value `1` was visited second, and so on. Our job is to verify that each consecutive pair of visits (from cell `k` to cell `k+1`) represents a valid knight move.
|
||||
|
||||
Imagine you're watching a replay of a chess game. You see where the knight was on move 0, move 1, move 2, etc. To verify it's valid, you just need to check: "Could a knight actually jump from the move-0 position to the move-1 position? From move-1 to move-2?" and so on.
|
||||
|
||||
A knight moves in an "L" shape: two squares in one direction and one square perpendicular to that. This gives us exactly 8 possible moves from any position. The key insight is that the **absolute difference** between row positions must be paired with a complementary difference in column positions: either (2,1) or (1,2).
|
||||
|
||||
approach: |
|
||||
We solve this using **Position Mapping and Sequential Validation**:
|
||||
|
||||
**Step 1: Build a position lookup**
|
||||
|
||||
- Create a mapping from move number to `(row, col)` position
|
||||
- Iterate through the grid and store `positions[grid[r][c]] = (r, c)` for each cell
|
||||
- This lets us quickly find where the knight was on any given move
|
||||
|
||||
|
||||
|
||||
**Step 2: Validate the starting position**
|
||||
|
||||
- Check that `grid[0][0] == 0` — the knight must start at the top-left cell
|
||||
- If this fails, return `false` immediately
|
||||
|
||||
|
||||
|
||||
**Step 3: Verify each consecutive move**
|
||||
|
||||
- For each move number from `0` to `n*n - 2`:
|
||||
- Get the position of move `k` and move `k+1`
|
||||
- Calculate the absolute row difference `dr` and column difference `dc`
|
||||
- A valid knight move requires either `(dr, dc) = (1, 2)` or `(dr, dc) = (2, 1)`
|
||||
- If any move is invalid, return `false`
|
||||
|
||||
|
||||
|
||||
**Step 4: Return the result**
|
||||
|
||||
- If all consecutive moves are valid, return `true`
|
||||
|
||||
common_pitfalls:
|
||||
- title: Forgetting the Starting Position Check
|
||||
description: |
|
||||
The problem states the knight must start at the **top-left cell**. This means `grid[0][0]` must equal `0`.
|
||||
|
||||
Some solutions skip this check and only validate the moves, but a grid where the knight starts elsewhere (like `grid[1][1] = 0`) would be invalid even if all moves are correct knight moves.
|
||||
wrong_approach: "Only checking if consecutive moves are valid knight moves"
|
||||
correct_approach: "First verify grid[0][0] == 0, then validate all moves"
|
||||
|
||||
- title: Checking All 8 Directions Instead of Just Validating Distances
|
||||
description: |
|
||||
You don't need to enumerate all 8 knight move directions and check if the next position matches one of them.
|
||||
|
||||
The simpler approach is to compute the absolute row and column differences between consecutive positions. A valid knight move has distances that are either `(1, 2)` or `(2, 1)`.
|
||||
wrong_approach: "Generating all 8 possible next positions and checking membership"
|
||||
correct_approach: "Check if (abs(dr), abs(dc)) forms a valid knight move pattern"
|
||||
|
||||
- title: Off-by-One in Move Iteration
|
||||
description: |
|
||||
The grid contains values from `0` to `n*n - 1`. When checking consecutive moves, you iterate from move `0` to move `n*n - 2` (not `n*n - 1`), comparing each move `k` with move `k+1`.
|
||||
|
||||
Going up to `n*n - 1` would cause an index-out-of-bounds error when accessing move `n*n`.
|
||||
|
||||
key_takeaways:
|
||||
- "**Position mapping**: Converting grid values to coordinates enables O(1) lookup for any move number"
|
||||
- "**Knight move validation**: A knight move is valid if the absolute differences in coordinates are `(1, 2)` or `(2, 1)`"
|
||||
- "**Sequential verification**: When validating a path, check each consecutive pair rather than trying to reconstruct the entire path"
|
||||
- "**Constraint checking**: Always verify initial conditions (starting position) before validating the sequence"
|
||||
|
||||
time_complexity: "O(n^2). We iterate through all n*n cells once to build the position map, then iterate through n*n - 1 consecutive pairs to validate moves."
|
||||
space_complexity: "O(n^2). We store a position lookup containing n*n entries mapping move numbers to coordinates."
|
||||
|
||||
solutions:
|
||||
- approach_name: Position Mapping
|
||||
is_optimal: true
|
||||
code: |
|
||||
def check_valid_grid(grid: list[list[int]]) -> bool:
|
||||
n = len(grid)
|
||||
|
||||
# Knight must start at top-left corner
|
||||
if grid[0][0] != 0:
|
||||
return False
|
||||
|
||||
# Build position lookup: move number -> (row, col)
|
||||
positions = {}
|
||||
for r in range(n):
|
||||
for c in range(n):
|
||||
positions[grid[r][c]] = (r, c)
|
||||
|
||||
# Verify each consecutive move is a valid knight move
|
||||
for move in range(n * n - 1):
|
||||
r1, c1 = positions[move]
|
||||
r2, c2 = positions[move + 1]
|
||||
|
||||
# Calculate absolute differences
|
||||
dr = abs(r2 - r1)
|
||||
dc = abs(c2 - c1)
|
||||
|
||||
# Valid knight move: (1,2) or (2,1)
|
||||
if not ((dr == 1 and dc == 2) or (dr == 2 and dc == 1)):
|
||||
return False
|
||||
|
||||
return True
|
||||
explanation: |
|
||||
**Time Complexity:** O(n^2) — We scan the grid once to build the lookup, then validate n^2 - 1 moves.
|
||||
|
||||
**Space Complexity:** O(n^2) — The position dictionary stores one entry per cell.
|
||||
|
||||
We first ensure the knight starts at (0,0), then build a mapping from move numbers to positions. Finally, we check each consecutive pair of moves to verify they form valid knight moves.
|
||||
|
||||
- approach_name: Direct Grid Search
|
||||
is_optimal: false
|
||||
code: |
|
||||
def check_valid_grid(grid: list[list[int]]) -> bool:
|
||||
n = len(grid)
|
||||
|
||||
# Knight must start at top-left corner
|
||||
if grid[0][0] != 0:
|
||||
return False
|
||||
|
||||
# All 8 possible knight moves
|
||||
knight_moves = [
|
||||
(-2, -1), (-2, 1), (-1, -2), (-1, 2),
|
||||
(1, -2), (1, 2), (2, -1), (2, 1)
|
||||
]
|
||||
|
||||
# Find position of move 0 (must be at 0,0)
|
||||
row, col = 0, 0
|
||||
|
||||
# Verify each move from 0 to n*n-2
|
||||
for move in range(n * n - 1):
|
||||
found = False
|
||||
|
||||
# Try all 8 knight moves to find the next position
|
||||
for dr, dc in knight_moves:
|
||||
nr, nc = row + dr, col + dc
|
||||
|
||||
# Check bounds and if this is the next move
|
||||
if 0 <= nr < n and 0 <= nc < n and grid[nr][nc] == move + 1:
|
||||
row, col = nr, nc
|
||||
found = True
|
||||
break
|
||||
|
||||
if not found:
|
||||
return False
|
||||
|
||||
return True
|
||||
explanation: |
|
||||
**Time Complexity:** O(n^2) — We check up to 8 neighbors for each of the n^2 moves.
|
||||
|
||||
**Space Complexity:** O(1) — We only store the current position and constants.
|
||||
|
||||
This approach follows the knight move-by-move. Starting from (0,0), we search all 8 possible knight destinations to find the cell containing the next move number. While this uses less space, it requires searching neighbors at each step.
|
||||
@@ -0,0 +1,225 @@
|
||||
title: Checking Existence of Edge Length Limited Paths
|
||||
slug: checking-existence-of-edge-length-limited-paths
|
||||
difficulty: hard
|
||||
leetcode_id: 1697
|
||||
leetcode_url: https://leetcode.com/problems/checking-existence-of-edge-length-limited-paths/
|
||||
categories:
|
||||
- graphs
|
||||
- sorting
|
||||
- arrays
|
||||
patterns:
|
||||
- union-find
|
||||
- two-pointers
|
||||
|
||||
description: |
|
||||
An undirected graph of `n` nodes is defined by `edgeList`, where `edgeList[i] = [u_i, v_i, dis_i]` denotes an edge between nodes `u_i` and `v_i` with distance `dis_i`. Note that there may be **multiple** edges between two nodes.
|
||||
|
||||
Given an array `queries`, where `queries[j] = [p_j, q_j, limit_j]`, your task is to determine for each `queries[j]` whether there is a path between `p_j` and `q_j` such that each edge on the path has a distance **strictly less than** `limit_j`.
|
||||
|
||||
Return *a boolean array* `answer`, *where* `answer.length == queries.length` *and the* `j`<sup>th</sup> *value of* `answer` *is* `true` *if there is a path for* `queries[j]`, *and* `false` *otherwise*.
|
||||
|
||||
constraints: |
|
||||
- `2 <= n <= 10^5`
|
||||
- `1 <= edgeList.length, queries.length <= 10^5`
|
||||
- `edgeList[i].length == 3`
|
||||
- `queries[j].length == 3`
|
||||
- `0 <= u_i, v_i, p_j, q_j <= n - 1`
|
||||
- `u_i != v_i`
|
||||
- `p_j != q_j`
|
||||
- `1 <= dis_i, limit_j <= 10^9`
|
||||
- There may be **multiple** edges between two nodes.
|
||||
|
||||
examples:
|
||||
- input: "n = 3, edgeList = [[0,1,2],[1,2,4],[2,0,8],[1,0,16]], queries = [[0,1,2],[0,2,5]]"
|
||||
output: "[false, true]"
|
||||
explanation: "For the first query, between 0 and 1 there is no path where each distance is less than 2 (the smallest edge is exactly 2, not strictly less). For the second query, there is a path (0 → 1 → 2) using edges with distances 2 and 4, both less than 5."
|
||||
- input: "n = 5, edgeList = [[0,1,10],[1,2,5],[2,3,9],[3,4,13]], queries = [[0,4,14],[1,4,13]]"
|
||||
output: "[true, false]"
|
||||
explanation: "For the first query, the path 0 → 1 → 2 → 3 → 4 uses edges with distances 10, 5, 9, 13 — all strictly less than 14. For the second query, node 4 requires the edge with distance 13, which is not strictly less than the limit 13."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Imagine you're building a road network incrementally. You start with no roads, then add them one by one, starting with the shortest roads first. As you add each road, previously disconnected cities become connected.
|
||||
|
||||
Now consider a query: "Can I travel from city A to city B using only roads shorter than X kilometres?" If you've already added all roads shorter than X, the answer depends entirely on whether A and B are in the same connected component at that moment.
|
||||
|
||||
This insight transforms the problem: instead of checking each query independently (which would be expensive), we can **process queries in order of their limits**. For a query with limit `L`, we first add all edges with distance `< L`, then check if the two nodes are connected.
|
||||
|
||||
Think of it like this: we're "growing" the graph by adding edges in sorted order, and each query asks "at what point in this growth are these two nodes connected?" By sorting both edges and queries by their distance/limit, we can answer all queries efficiently using a single pass through both.
|
||||
|
||||
approach: |
|
||||
We use **Offline Query Processing with Union-Find**:
|
||||
|
||||
**Step 1: Prepare the data structures**
|
||||
|
||||
- Create a Union-Find (Disjoint Set Union) structure for `n` nodes
|
||||
- Sort `edgeList` by distance in ascending order
|
||||
- Create an indexed copy of queries: `[(limit, p, q, original_index), ...]`
|
||||
- Sort queries by limit in ascending order
|
||||
- Initialise result array of size `len(queries)`
|
||||
|
||||
|
||||
|
||||
**Step 2: Process queries in order of increasing limit**
|
||||
|
||||
- Maintain an edge pointer `edge_idx` starting at `0`
|
||||
- For each query `(limit, p, q, idx)` in sorted order:
|
||||
- Add all edges with `distance < limit` to the Union-Find structure
|
||||
- This is done by unioning edges while `edge_idx < len(edgeList)` and `edgeList[edge_idx].distance < limit`
|
||||
- Check if `p` and `q` are in the same connected component using `find(p) == find(q)`
|
||||
- Store the result at position `idx` in the result array
|
||||
|
||||
|
||||
|
||||
**Step 3: Return the result array**
|
||||
|
||||
- The result array now contains answers in the original query order
|
||||
|
||||
|
||||
|
||||
The key insight is that because both edges and queries are sorted, each edge is added to the Union-Find exactly once. When we process a query with limit `L`, all edges with distance `< L` have already been added, so we just need to check connectivity.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Processing Queries Independently
|
||||
description: |
|
||||
A naive approach might process each query separately: for each query, filter edges with distance `< limit`, build a graph, and run BFS/DFS to check connectivity.
|
||||
|
||||
This results in **O(Q × (E + N))** time where Q is queries, E is edges, and N is nodes. With `Q = E = 10^5`, this means up to 10^10 operations — far too slow.
|
||||
|
||||
The offline approach processes all queries together in **O((E + Q) log(E + Q) + E × α(N))** time, where α is the inverse Ackermann function (effectively constant).
|
||||
wrong_approach: "BFS/DFS for each query independently"
|
||||
correct_approach: "Sort queries by limit and use Union-Find incrementally"
|
||||
|
||||
- title: Forgetting Strictly Less Than
|
||||
description: |
|
||||
The problem requires edges with distance **strictly less than** the limit, not less than or equal. When processing a query with `limit = 5`, you must only add edges with distance `< 5`, not `<= 5`.
|
||||
|
||||
For example, with `limit = 2` and an edge of distance `2`, that edge should NOT be included. This is easy to miss and causes wrong answers.
|
||||
wrong_approach: "Using `distance <= limit` when adding edges"
|
||||
correct_approach: "Using `distance < limit` when adding edges"
|
||||
|
||||
- title: Not Preserving Query Order
|
||||
description: |
|
||||
Since we process queries in sorted order by limit, we must track the original index of each query to place results in the correct position.
|
||||
|
||||
If you forget to track original indices, you'll return answers in the wrong order, which fails the test cases even if the connectivity logic is correct.
|
||||
wrong_approach: "Returning results in sorted order"
|
||||
correct_approach: "Storing original index and placing results at that position"
|
||||
|
||||
- title: Inefficient Union-Find Implementation
|
||||
description: |
|
||||
A basic Union-Find without optimisations can degrade to O(N) per operation. With `10^5` edges, this becomes too slow.
|
||||
|
||||
Use **path compression** in `find()` and **union by rank/size** in `union()` to achieve near-constant time per operation.
|
||||
wrong_approach: "Union-Find without path compression"
|
||||
correct_approach: "Union-Find with path compression and union by rank"
|
||||
|
||||
key_takeaways:
|
||||
- "**Offline query processing**: When queries can be answered in any order, sorting them can enable efficient batch processing"
|
||||
- "**Union-Find for dynamic connectivity**: Perfect for incrementally adding edges and checking if nodes are connected"
|
||||
- "**Two-pointer technique on sorted data**: Sorting both edges and queries by distance allows single-pass processing"
|
||||
- "**Pattern recognition**: Problems asking 'is there a path with constraint X' often benefit from processing in order of that constraint"
|
||||
|
||||
time_complexity: "O((E + Q) log(E + Q) + E × α(N)). Sorting edges and queries dominates at O((E + Q) log(E + Q)). Each union/find operation takes O(α(N)) amortised time, where α is the inverse Ackermann function (effectively constant for all practical inputs)."
|
||||
space_complexity: "O(N + Q). We store the Union-Find parent and rank arrays (O(N)) and the indexed queries array (O(Q))."
|
||||
|
||||
solutions:
|
||||
- approach_name: Offline Query Processing with Union-Find
|
||||
is_optimal: true
|
||||
code: |
|
||||
def distance_limited_paths_exist(
|
||||
n: int, edge_list: list[list[int]], queries: list[list[int]]
|
||||
) -> list[bool]:
|
||||
# Union-Find with path compression and union by rank
|
||||
parent = list(range(n))
|
||||
rank = [0] * n
|
||||
|
||||
def find(x: int) -> int:
|
||||
# Path compression: make every node point directly to root
|
||||
if parent[x] != x:
|
||||
parent[x] = find(parent[x])
|
||||
return parent[x]
|
||||
|
||||
def union(x: int, y: int) -> None:
|
||||
# Union by rank: attach smaller tree under larger tree
|
||||
px, py = find(x), find(y)
|
||||
if px == py:
|
||||
return
|
||||
if rank[px] < rank[py]:
|
||||
px, py = py, px
|
||||
parent[py] = px
|
||||
if rank[px] == rank[py]:
|
||||
rank[px] += 1
|
||||
|
||||
# Sort edges by distance
|
||||
edge_list.sort(key=lambda e: e[2])
|
||||
|
||||
# Create indexed queries and sort by limit
|
||||
# Format: (limit, p, q, original_index)
|
||||
indexed_queries = [(q[2], q[0], q[1], i) for i, q in enumerate(queries)]
|
||||
indexed_queries.sort()
|
||||
|
||||
result = [False] * len(queries)
|
||||
edge_idx = 0
|
||||
|
||||
# Process queries in order of increasing limit
|
||||
for limit, p, q, idx in indexed_queries:
|
||||
# Add all edges with distance strictly less than limit
|
||||
while edge_idx < len(edge_list) and edge_list[edge_idx][2] < limit:
|
||||
u, v, _ = edge_list[edge_idx]
|
||||
union(u, v)
|
||||
edge_idx += 1
|
||||
|
||||
# Check if p and q are connected
|
||||
result[idx] = find(p) == find(q)
|
||||
|
||||
return result
|
||||
explanation: |
|
||||
**Time Complexity:** O((E + Q) log(E + Q) + E × α(N)) — Sorting dominates; Union-Find operations are nearly constant.
|
||||
|
||||
**Space Complexity:** O(N + Q) — Union-Find arrays plus indexed queries.
|
||||
|
||||
By sorting both edges and queries by distance/limit, we process everything in a single pass. Each edge is added exactly once to the Union-Find. When answering a query with limit L, all edges with distance < L have been added, so connectivity check is just comparing roots.
|
||||
|
||||
- approach_name: BFS/DFS Per Query (Brute Force)
|
||||
is_optimal: false
|
||||
code: |
|
||||
from collections import defaultdict, deque
|
||||
|
||||
def distance_limited_paths_exist(
|
||||
n: int, edge_list: list[list[int]], queries: list[list[int]]
|
||||
) -> list[bool]:
|
||||
result = []
|
||||
|
||||
for p, q, limit in queries:
|
||||
# Build adjacency list with only valid edges
|
||||
graph = defaultdict(list)
|
||||
for u, v, dist in edge_list:
|
||||
if dist < limit:
|
||||
graph[u].append(v)
|
||||
graph[v].append(u)
|
||||
|
||||
# BFS to check if p and q are connected
|
||||
visited = set([p])
|
||||
queue = deque([p])
|
||||
found = False
|
||||
|
||||
while queue and not found:
|
||||
node = queue.popleft()
|
||||
if node == q:
|
||||
found = True
|
||||
break
|
||||
for neighbor in graph[node]:
|
||||
if neighbor not in visited:
|
||||
visited.add(neighbor)
|
||||
queue.append(neighbor)
|
||||
|
||||
result.append(found)
|
||||
|
||||
return result
|
||||
explanation: |
|
||||
**Time Complexity:** O(Q × (E + N)) — For each query, we filter edges and run BFS.
|
||||
|
||||
**Space Complexity:** O(E + N) — Adjacency list and visited set per query.
|
||||
|
||||
This approach processes each query independently: filter edges below the limit, build a graph, run BFS to check connectivity. While correct, it's far too slow for the constraints (`Q = E = 10^5` means up to 10^10 operations). Included to illustrate why offline processing with Union-Find is essential.
|
||||
217
backend/data/questions/clone-graph.yaml
Normal file
217
backend/data/questions/clone-graph.yaml
Normal file
@@ -0,0 +1,217 @@
|
||||
title: Clone Graph
|
||||
slug: clone-graph
|
||||
difficulty: medium
|
||||
leetcode_id: 133
|
||||
leetcode_url: https://leetcode.com/problems/clone-graph/
|
||||
categories:
|
||||
- graphs
|
||||
- hash-tables
|
||||
patterns:
|
||||
- dfs
|
||||
- bfs
|
||||
|
||||
description: |
|
||||
Given a reference of a node in a **connected** undirected graph.
|
||||
|
||||
Return a **deep copy** (clone) of the graph.
|
||||
|
||||
Each node in the graph contains a value (`int`) and a list (`List[Node]`) of its neighbors.
|
||||
|
||||
```
|
||||
class Node:
|
||||
def __init__(self, val = 0, neighbors = None):
|
||||
self.val = val
|
||||
self.neighbors = neighbors if neighbors is not None else []
|
||||
```
|
||||
|
||||
**Test case format:**
|
||||
|
||||
For simplicity, each node's value is the same as the node's index (1-indexed). For example, the first node with `val == 1`, the second node with `val == 2`, and so on. The graph is represented in the test case using an adjacency list.
|
||||
|
||||
An **adjacency list** is a collection of unordered lists used to represent a finite graph. Each list describes the set of neighbors of a node in the graph.
|
||||
|
||||
The given node will always be the first node with `val = 1`. You must return the **copy of the given node** as a reference to the cloned graph.
|
||||
|
||||
constraints: |
|
||||
- `0 <= Number of nodes <= 100`
|
||||
- `1 <= Node.val <= 100`
|
||||
- `Node.val` is unique for each node
|
||||
- There are no repeated edges and no self-loops in the graph
|
||||
- The graph is connected and all nodes can be visited starting from the given node
|
||||
|
||||
examples:
|
||||
- input: "adjList = [[2,4],[1,3],[2,4],[1,3]]"
|
||||
output: "[[2,4],[1,3],[2,4],[1,3]]"
|
||||
explanation: "There are 4 nodes in the graph. 1st node's neighbors are 2nd and 4th nodes. 2nd node's neighbors are 1st and 3rd nodes. 3rd node's neighbors are 2nd and 4th nodes. 4th node's neighbors are 1st and 3rd nodes."
|
||||
- input: "adjList = [[]]"
|
||||
output: "[[]]"
|
||||
explanation: "The graph consists of only one node with val = 1 and it does not have any neighbors."
|
||||
- input: "adjList = []"
|
||||
output: "[]"
|
||||
explanation: "This is an empty graph with no nodes."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Imagine you're making a photocopy of a web of interconnected sticky notes. Each sticky note has a number and strings connecting it to other notes. You can't just copy the notes — you also need to recreate all the strings connecting them to the *new* copies, not the originals.
|
||||
|
||||
The core challenge is handling **cycles and shared references**. If node A connects to node B, and node B connects back to node A, when you clone A and try to clone its neighbor B, then clone B's neighbors, you'll encounter A again. Without tracking what you've already cloned, you'd create infinite copies!
|
||||
|
||||
Think of it like this: as you explore the graph, you need a **guest list** (hash map) that records "original node → cloned node". Before cloning any node, check if it's already on the list. If yes, return the existing clone. If no, create a new clone and add it to the list.
|
||||
|
||||
This pattern — using a hash map to track visited/cloned nodes — is fundamental to graph traversal problems where you need to avoid infinite loops or duplicate processing.
|
||||
|
||||
approach: |
|
||||
We solve this using **DFS with a Hash Map** to track cloned nodes:
|
||||
|
||||
**Step 1: Handle the base case**
|
||||
|
||||
- If the input node is `None`, return `None` immediately
|
||||
- An empty graph has no nodes to clone
|
||||
|
||||
|
||||
|
||||
**Step 2: Initialise the visited map**
|
||||
|
||||
- `visited`: A hash map that maps original nodes to their clones
|
||||
- This serves two purposes: tracking what we've cloned AND providing quick access to clones when building neighbor lists
|
||||
|
||||
|
||||
|
||||
**Step 3: Define the recursive clone function**
|
||||
|
||||
- If the current node is already in `visited`, return its clone (this handles cycles!)
|
||||
- Otherwise, create a new node with the same value
|
||||
- Add the mapping `original → clone` to `visited` BEFORE recursing (crucial for cycle handling)
|
||||
- Recursively clone each neighbor and add to the clone's neighbor list
|
||||
- Return the cloned node
|
||||
|
||||
|
||||
|
||||
**Step 4: Start the traversal**
|
||||
|
||||
- Call the clone function on the starting node
|
||||
- The recursion will naturally traverse the entire connected graph
|
||||
- Return the clone of the starting node
|
||||
|
||||
|
||||
|
||||
The DFS approach naturally explores the graph depth-first, and the hash map ensures each node is cloned exactly once regardless of how many times we encounter it.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Infinite Recursion from Cycles
|
||||
description: |
|
||||
Graphs can have cycles — node A connects to B, which connects back to A. Without tracking visited nodes, you'll recurse forever:
|
||||
|
||||
```
|
||||
clone(A) → clone neighbor B → clone neighbor A → clone neighbor B → ...
|
||||
```
|
||||
|
||||
The fix is to add the clone to your visited map **before** recursing into neighbors. This way, when you encounter a node you're currently processing, you return the partially-built clone instead of creating a new one.
|
||||
wrong_approach: "Clone neighbors without checking if already visited"
|
||||
correct_approach: "Add to visited map before recursing, check visited before cloning"
|
||||
|
||||
- title: Connecting to Original Nodes Instead of Clones
|
||||
description: |
|
||||
A subtle bug: when building the clone's neighbor list, you might accidentally add the *original* neighbor nodes instead of their *clones*:
|
||||
|
||||
```python
|
||||
# WRONG: connects clone to original neighbors
|
||||
clone.neighbors = node.neighbors
|
||||
|
||||
# RIGHT: connects clone to cloned neighbors
|
||||
clone.neighbors = [clone_node(n) for n in node.neighbors]
|
||||
```
|
||||
|
||||
The result would be a "clone" that's actually interconnected with the original graph — not a true deep copy.
|
||||
wrong_approach: "Copying the neighbors list reference directly"
|
||||
correct_approach: "Recursively clone each neighbor and build a new list"
|
||||
|
||||
- title: Forgetting the Empty Graph Case
|
||||
description: |
|
||||
When the input is `None` (empty graph), you must return `None`. Attempting to access `.val` or `.neighbors` on `None` will cause a runtime error.
|
||||
|
||||
Always handle the base case first before any other logic.
|
||||
|
||||
key_takeaways:
|
||||
- "**Hash map for graph cloning**: The pattern of mapping `original → clone` is essential for any deep copy operation on graph structures"
|
||||
- "**Add to visited before recursing**: This prevents infinite loops in cyclic graphs — add the node to your tracking structure *before* processing its neighbors"
|
||||
- "**DFS vs BFS both work**: This problem can be solved with either traversal; DFS uses the call stack, BFS uses an explicit queue"
|
||||
- "**Foundation for graph problems**: This cloning technique applies to problems like copying linked lists with random pointers, serialising/deserialising graphs, and more"
|
||||
|
||||
time_complexity: "O(n + e) where `n` is the number of nodes and `e` is the number of edges. We visit each node once and traverse each edge once."
|
||||
space_complexity: "O(n). The hash map stores one entry per node, and the recursion stack can be at most `n` deep for a linear graph."
|
||||
|
||||
solutions:
|
||||
- approach_name: DFS with Hash Map
|
||||
is_optimal: true
|
||||
code: |
|
||||
class Solution:
|
||||
def cloneGraph(self, node: 'Node') -> 'Node':
|
||||
if not node:
|
||||
return None
|
||||
|
||||
# Map from original node to its clone
|
||||
visited = {}
|
||||
|
||||
def clone(node: 'Node') -> 'Node':
|
||||
# If already cloned, return the clone (handles cycles)
|
||||
if node in visited:
|
||||
return visited[node]
|
||||
|
||||
# Create a new node with the same value
|
||||
clone_node = Node(node.val)
|
||||
|
||||
# IMPORTANT: Add to visited BEFORE recursing to handle cycles
|
||||
visited[node] = clone_node
|
||||
|
||||
# Recursively clone all neighbors
|
||||
for neighbor in node.neighbors:
|
||||
clone_node.neighbors.append(clone(neighbor))
|
||||
|
||||
return clone_node
|
||||
|
||||
return clone(node)
|
||||
explanation: |
|
||||
**Time Complexity:** O(n + e) — We visit each node once and process each edge once.
|
||||
|
||||
**Space Complexity:** O(n) — The hash map stores n entries, and recursion depth can be O(n).
|
||||
|
||||
The DFS approach uses recursion to traverse the graph. The key insight is adding the clone to the visited map *before* recursing into neighbors — this ensures that when we encounter a cycle, we return the existing clone rather than creating infinite copies.
|
||||
|
||||
- approach_name: BFS with Hash Map
|
||||
is_optimal: true
|
||||
code: |
|
||||
from collections import deque
|
||||
|
||||
class Solution:
|
||||
def cloneGraph(self, node: 'Node') -> 'Node':
|
||||
if not node:
|
||||
return None
|
||||
|
||||
# Map from original node to its clone
|
||||
visited = {node: Node(node.val)}
|
||||
|
||||
# BFS queue contains original nodes to process
|
||||
queue = deque([node])
|
||||
|
||||
while queue:
|
||||
current = queue.popleft()
|
||||
|
||||
# Process each neighbor of the current node
|
||||
for neighbor in current.neighbors:
|
||||
if neighbor not in visited:
|
||||
# Clone the neighbor and add to visited
|
||||
visited[neighbor] = Node(neighbor.val)
|
||||
# Add to queue for processing its neighbors
|
||||
queue.append(neighbor)
|
||||
|
||||
# Connect the clone to its cloned neighbor
|
||||
visited[current].neighbors.append(visited[neighbor])
|
||||
|
||||
return visited[node]
|
||||
explanation: |
|
||||
**Time Complexity:** O(n + e) — Same as DFS, we visit each node and edge once.
|
||||
|
||||
**Space Complexity:** O(n) — Hash map stores n entries, queue can hold up to n nodes.
|
||||
|
||||
BFS uses an explicit queue instead of recursion. We clone nodes when we first discover them (adding to both visited and queue), then connect clones to their neighbors as we process each node. This approach is often preferred when you want to avoid deep recursion stacks.
|
||||
221
backend/data/questions/coin-change-ii.yaml
Normal file
221
backend/data/questions/coin-change-ii.yaml
Normal file
@@ -0,0 +1,221 @@
|
||||
title: Coin Change II
|
||||
slug: coin-change-ii
|
||||
difficulty: medium
|
||||
leetcode_id: 518
|
||||
leetcode_url: https://leetcode.com/problems/coin-change-ii/
|
||||
categories:
|
||||
- arrays
|
||||
- dynamic-programming
|
||||
patterns:
|
||||
- dynamic-programming
|
||||
|
||||
description: |
|
||||
You are given an integer array `coins` representing coins of different denominations and an integer `amount` representing a total amount of money.
|
||||
|
||||
Return *the number of combinations that make up that amount*. If that amount of money cannot be made up by any combination of the coins, return `0`.
|
||||
|
||||
You may assume that you have an infinite number of each kind of coin.
|
||||
|
||||
The answer is **guaranteed** to fit into a signed **32-bit** integer.
|
||||
|
||||
constraints: |
|
||||
- `1 <= coins.length <= 300`
|
||||
- `1 <= coins[i] <= 5000`
|
||||
- All the values of `coins` are **unique**
|
||||
- `0 <= amount <= 5000`
|
||||
|
||||
examples:
|
||||
- input: "amount = 5, coins = [1,2,5]"
|
||||
output: "4"
|
||||
explanation: "There are four ways to make up the amount: 5=5, 5=2+2+1, 5=2+1+1+1, 5=1+1+1+1+1"
|
||||
- input: "amount = 3, coins = [2]"
|
||||
output: "0"
|
||||
explanation: "The amount of 3 cannot be made up just with coins of 2."
|
||||
- input: "amount = 10, coins = [10]"
|
||||
output: "1"
|
||||
explanation: "There is only one way: use a single coin of denomination 10."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Imagine you're a cashier with unlimited coins of certain denominations, and you need to count how many *distinct* ways you can give change for a specific amount.
|
||||
|
||||
The key insight is understanding the difference between **combinations** and **permutations**. If you have coins `[1, 2]` and need to make amount `3`, the combinations `1+2` and `2+1` are the **same** — they both use one coin of each type. We only count this once.
|
||||
|
||||
Think of it like filling a shopping bag: the order you put items in doesn't matter, only *what* items end up in the bag. To avoid counting the same combination multiple times, we process **one coin type at a time**. For each coin, we ask: "How many ways can I make each amount using this coin (zero or more times) plus coins I've already considered?"
|
||||
|
||||
This is the classic **Unbounded Knapsack** pattern where each item (coin) can be used unlimited times, and we're counting combinations, not finding a minimum.
|
||||
|
||||
approach: |
|
||||
We solve this using **1D Dynamic Programming** with a space-optimised approach:
|
||||
|
||||
**Step 1: Define the DP array**
|
||||
|
||||
- `dp[i]`: The number of ways to make amount `i` using the coins considered so far
|
||||
- Size: `amount + 1` (to include amount `0` through `amount`)
|
||||
- Initial value: `dp[0] = 1` (there's exactly one way to make amount `0`: use no coins)
|
||||
|
||||
|
||||
|
||||
**Step 2: Process coins one by one (outer loop)**
|
||||
|
||||
- Iterate through each coin in `coins`
|
||||
- This ensures we count **combinations**, not permutations
|
||||
- By fixing the coin order, `[1, 2]` and `[2, 1]` won't be counted separately
|
||||
|
||||
|
||||
|
||||
**Step 3: Update amounts that can use this coin (inner loop)**
|
||||
|
||||
- For each coin, iterate through amounts from `coin` to `amount`
|
||||
- For each amount `a`, add `dp[a - coin]` to `dp[a]`
|
||||
- This represents: "ways to make amount `a` by using at least one of this coin"
|
||||
|
||||
|
||||
|
||||
**Step 4: Return the result**
|
||||
|
||||
- Return `dp[amount]`, which contains the total number of combinations
|
||||
|
||||
|
||||
|
||||
The key to avoiding duplicate counting is the loop order: coins in the outer loop, amounts in the inner loop. This ensures each combination is counted exactly once.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Counting Permutations Instead of Combinations
|
||||
description: |
|
||||
If you swap the loop order (amounts in outer, coins in inner), you'll count **permutations** instead of combinations.
|
||||
|
||||
For example, with `coins = [1, 2]` and `amount = 3`:
|
||||
- Correct (combinations): `[1,1,1], [1,2]` → 2 ways
|
||||
- Wrong (permutations): `[1,1,1], [1,2], [2,1]` → 3 ways
|
||||
|
||||
The `[1,2]` and `[2,1]` are the same combination but different permutations. Processing coins in the outer loop ensures each coin type is considered in a fixed order.
|
||||
wrong_approach: "Outer loop over amounts, inner loop over coins"
|
||||
correct_approach: "Outer loop over coins, inner loop over amounts"
|
||||
|
||||
- title: Wrong Base Case
|
||||
description: |
|
||||
Forgetting to initialise `dp[0] = 1` is a common mistake. There is exactly **one way** to make amount `0`: use no coins at all.
|
||||
|
||||
If you initialise `dp[0] = 0`, all subsequent values remain `0` since there's no base case to build from.
|
||||
wrong_approach: "dp[0] = 0 or leaving it uninitialised"
|
||||
correct_approach: "dp[0] = 1 (one way to make zero: use nothing)"
|
||||
|
||||
- title: Inner Loop Starting Point
|
||||
description: |
|
||||
The inner loop must start from `coin`, not from `0` or `1`.
|
||||
|
||||
- Starting from `0` would try to access `dp[negative]`
|
||||
- Starting from `1` would miss the case where `amount == coin`
|
||||
|
||||
We can only add a coin to amounts >= its value.
|
||||
wrong_approach: "for a in range(amount + 1)"
|
||||
correct_approach: "for a in range(coin, amount + 1)"
|
||||
|
||||
- title: Confusing with Coin Change I
|
||||
description: |
|
||||
Coin Change I asks for the **minimum number** of coins, while Coin Change II asks for the **number of combinations**.
|
||||
|
||||
They require different DP formulations:
|
||||
- Coin Change I: `dp[a] = min(dp[a], dp[a - coin] + 1)`
|
||||
- Coin Change II: `dp[a] = dp[a] + dp[a - coin]`
|
||||
|
||||
Using `min` here would give incorrect results.
|
||||
|
||||
key_takeaways:
|
||||
- "**Unbounded Knapsack pattern**: When items can be reused unlimited times and order doesn't matter, use the 'coin outer, amount inner' loop structure"
|
||||
- "**Combinations vs Permutations**: Loop order determines whether you count ordered or unordered selections — this is a critical distinction in DP problems"
|
||||
- "**Space optimisation**: 2D DP can often be reduced to 1D when each row only depends on the previous row (or itself)"
|
||||
- "**Related problems**: This extends to Coin Change I (minimum coins), Combination Sum IV (permutations), and knapsack variants"
|
||||
|
||||
time_complexity: "O(n × amount). We iterate through each of the `n` coins, and for each coin, we iterate through amounts from `coin` to `amount`."
|
||||
space_complexity: "O(amount). We use a 1D array of size `amount + 1` to store the number of combinations for each amount."
|
||||
|
||||
solutions:
|
||||
- approach_name: 1D Dynamic Programming
|
||||
is_optimal: true
|
||||
code: |
|
||||
def change(amount: int, coins: list[int]) -> int:
|
||||
# dp[i] = number of ways to make amount i
|
||||
dp = [0] * (amount + 1)
|
||||
|
||||
# Base case: one way to make amount 0 (use no coins)
|
||||
dp[0] = 1
|
||||
|
||||
# Process each coin type one at a time
|
||||
# This ensures we count combinations, not permutations
|
||||
for coin in coins:
|
||||
# For each amount that can use this coin
|
||||
for a in range(coin, amount + 1):
|
||||
# Add ways to make (a - coin) using coins considered so far
|
||||
dp[a] += dp[a - coin]
|
||||
|
||||
return dp[amount]
|
||||
explanation: |
|
||||
**Time Complexity:** O(n × amount) — Nested loops over coins and amounts.
|
||||
|
||||
**Space Complexity:** O(amount) — Single array of size `amount + 1`.
|
||||
|
||||
By processing coins in the outer loop, we ensure each coin type is added in a fixed order, avoiding duplicate combinations. The inner loop accumulates ways to use the current coin (zero or more times) with previously processed coins.
|
||||
|
||||
- approach_name: 2D Dynamic Programming
|
||||
is_optimal: false
|
||||
code: |
|
||||
def change(amount: int, coins: list[int]) -> int:
|
||||
n = len(coins)
|
||||
# dp[i][a] = ways to make amount a using first i coin types
|
||||
dp = [[0] * (amount + 1) for _ in range(n + 1)]
|
||||
|
||||
# Base case: one way to make amount 0 with any set of coins
|
||||
for i in range(n + 1):
|
||||
dp[i][0] = 1
|
||||
|
||||
for i in range(1, n + 1):
|
||||
coin = coins[i - 1]
|
||||
for a in range(amount + 1):
|
||||
# Don't use this coin type
|
||||
dp[i][a] = dp[i - 1][a]
|
||||
# Use this coin type (if possible)
|
||||
if a >= coin:
|
||||
dp[i][a] += dp[i][a - coin]
|
||||
|
||||
return dp[n][amount]
|
||||
explanation: |
|
||||
**Time Complexity:** O(n × amount) — Same as 1D approach.
|
||||
|
||||
**Space Complexity:** O(n × amount) — 2D array storing states for each coin prefix.
|
||||
|
||||
This explicit 2D formulation makes the state transition clearer: `dp[i][a]` represents ways to make amount `a` using only the first `i` coin types. The 1D solution is a space optimisation of this, possible because row `i` only depends on row `i` and row `i-1`.
|
||||
|
||||
- approach_name: Recursive with Memoization
|
||||
is_optimal: false
|
||||
code: |
|
||||
def change(amount: int, coins: list[int]) -> int:
|
||||
from functools import lru_cache
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def count_ways(coin_index: int, remaining: int) -> int:
|
||||
# Base case: exact amount achieved
|
||||
if remaining == 0:
|
||||
return 1
|
||||
# Base case: no more coins or overshot
|
||||
if coin_index >= len(coins) or remaining < 0:
|
||||
return 0
|
||||
|
||||
coin = coins[coin_index]
|
||||
|
||||
# Choice 1: Skip this coin type entirely
|
||||
skip = count_ways(coin_index + 1, remaining)
|
||||
|
||||
# Choice 2: Use at least one of this coin (can use more)
|
||||
use = count_ways(coin_index, remaining - coin)
|
||||
|
||||
return skip + use
|
||||
|
||||
return count_ways(0, amount)
|
||||
explanation: |
|
||||
**Time Complexity:** O(n × amount) — Each unique state `(coin_index, remaining)` is computed once.
|
||||
|
||||
**Space Complexity:** O(n × amount) — Memoization cache plus recursion stack.
|
||||
|
||||
This top-down approach makes the decision tree explicit: at each coin, we either skip it entirely or use at least one. The `coin_index` parameter ensures we process coins in order, counting combinations rather than permutations. Less efficient than iterative DP due to function call overhead.
|
||||
260
backend/data/questions/combination-sum-ii.yaml
Normal file
260
backend/data/questions/combination-sum-ii.yaml
Normal file
@@ -0,0 +1,260 @@
|
||||
title: Combination Sum II
|
||||
slug: combination-sum-ii
|
||||
difficulty: medium
|
||||
leetcode_id: 40
|
||||
leetcode_url: https://leetcode.com/problems/combination-sum-ii/
|
||||
categories:
|
||||
- arrays
|
||||
- recursion
|
||||
patterns:
|
||||
- backtracking
|
||||
|
||||
description: |
|
||||
Given a collection of candidate numbers (`candidates`) and a target number (`target`), find all unique combinations in `candidates` where the candidate numbers sum to `target`.
|
||||
|
||||
Each number in `candidates` may only be used **once** in the combination.
|
||||
|
||||
**Note:** The solution set must not contain duplicate combinations.
|
||||
|
||||
constraints: |
|
||||
- `1 <= candidates.length <= 100`
|
||||
- `1 <= candidates[i] <= 50`
|
||||
- `1 <= target <= 30`
|
||||
|
||||
examples:
|
||||
- input: "candidates = [10,1,2,7,6,1,5], target = 8"
|
||||
output: "[[1,1,6],[1,2,5],[1,7],[2,6]]"
|
||||
explanation: "The unique combinations that sum to 8 are: [1,1,6], [1,2,5], [1,7], and [2,6]. Note that [1,1,6] uses both 1s from the input."
|
||||
- input: "candidates = [2,5,2,1,2], target = 5"
|
||||
output: "[[1,2,2],[5]]"
|
||||
explanation: "Two unique combinations sum to 5: [1,2,2] and [5]."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Imagine you have a bag of coins where some coins have the same value. You need to find all ways to make exact change for a target amount, but once you use a coin, it's gone — you can't use the same physical coin twice.
|
||||
|
||||
The challenge compared to Combination Sum I is twofold:
|
||||
1. **No reuse**: Each element can only be used once, so after picking an element, we must move to the next index
|
||||
2. **Duplicates in input**: The input may contain duplicate values like `[1,1,2]`, and we need to avoid generating duplicate combinations
|
||||
|
||||
Think of it like this: if you have two coins both worth 1, picking the first coin vs. the second coin leads to the same combination `[1,...]`. To avoid counting this twice, we need a way to **skip duplicate values at the same decision level**.
|
||||
|
||||
The key insight is that after sorting, duplicates are adjacent. At each decision point in our backtracking tree, if we've already explored a branch starting with a particular value, we should skip all other occurrences of that same value at that level. However, we *can* use duplicate values in the same combination — we just can't start multiple branches with the same value at the same tree level.
|
||||
|
||||
approach: |
|
||||
We solve this using **Backtracking with Duplicate Skipping**:
|
||||
|
||||
**Step 1: Sort the candidates**
|
||||
|
||||
- Sorting brings duplicate values together, making them easy to detect and skip
|
||||
- It also enables pruning: once a candidate exceeds the remaining target, we can stop
|
||||
|
||||
|
||||
|
||||
**Step 2: Define the recursive backtrack function**
|
||||
|
||||
- `backtrack(start, remaining, current_combination)`
|
||||
- `start`: index of the first candidate we can consider (elements before are already "used" or skipped)
|
||||
- `remaining`: how much more we need to reach the target
|
||||
- `current_combination`: the combination being built
|
||||
|
||||
|
||||
|
||||
**Step 3: Base case — target reached**
|
||||
|
||||
- If `remaining == 0`, we've found a valid combination
|
||||
- Add a **copy** of `current_combination` to results
|
||||
- Return to explore other paths
|
||||
|
||||
|
||||
|
||||
**Step 4: Recursive case with duplicate skipping**
|
||||
|
||||
- Loop through candidates starting from index `start`
|
||||
- For each candidate at index `i`:
|
||||
- **Skip duplicates at same level**: If `i > start` and `candidates[i] == candidates[i-1]`, skip this candidate
|
||||
- **Prune**: If `candidates[i] > remaining`, break (no point trying larger candidates)
|
||||
- **Choose**: Add `candidates[i]` to `current_combination`
|
||||
- **Explore**: Recursively call `backtrack(i + 1, remaining - candidates[i], current_combination)`
|
||||
- Note: we pass `i + 1`, not `i`, because each element can only be used once
|
||||
- **Unchoose**: Remove the last element (backtrack)
|
||||
|
||||
|
||||
|
||||
**Step 5: Return all valid combinations**
|
||||
|
||||
- Call `backtrack(0, target, [])` to start the search
|
||||
- Return the collected results
|
||||
|
||||
common_pitfalls:
|
||||
- title: Generating Duplicate Combinations
|
||||
description: |
|
||||
Without proper duplicate handling, an input like `[1,1,2]` with target 3 would generate `[1,2]` twice — once using the first 1, once using the second 1.
|
||||
|
||||
The fix requires understanding *where* to skip: we only skip duplicates **at the same decision level**. The condition `i > start and candidates[i] == candidates[i-1]` ensures we skip duplicates only when they would start a new branch at the same level, not when they're used deeper in the same path.
|
||||
|
||||
For example, with sorted `[1,1,2]` and target 3:
|
||||
- First branch: pick index 0 (value 1), then can pick index 1 (value 1) → `[1,1,...]`
|
||||
- At the top level, skip index 1 (value 1) because we already tried 1 at this level
|
||||
wrong_approach: "No duplicate checking, or skipping all duplicates everywhere"
|
||||
correct_approach: "Skip duplicates only at the same tree level using i > start check"
|
||||
|
||||
- title: Reusing Elements (Wrong Recursion Index)
|
||||
description: |
|
||||
In Combination Sum I, we recurse with index `i` to allow reuse. Here, each element can only be used once, so we must recurse with `i + 1`.
|
||||
|
||||
If you accidentally pass `i` instead of `i + 1`, you'll generate invalid combinations that use the same element multiple times, like `[2,2,2]` from an input that only contains one 2.
|
||||
wrong_approach: "backtrack(i, ...) after choosing candidates[i]"
|
||||
correct_approach: "backtrack(i + 1, ...) to move past the used element"
|
||||
|
||||
- title: Incorrect Duplicate Skip Condition
|
||||
description: |
|
||||
A common mistake is using just `candidates[i] == candidates[i-1]` without the `i > start` check. This would incorrectly skip valid uses of duplicates within the same combination.
|
||||
|
||||
For example, with `[1,1,6]` and target 8, we want to allow the combination `[1,1,6]`. The second 1 is at index 1, and when we're exploring from start=1 (after picking the first 1), we have `i == start`, so we *don't* skip — correctly allowing `[1,1,6]`.
|
||||
wrong_approach: "if candidates[i] == candidates[i-1]: continue"
|
||||
correct_approach: "if i > start and candidates[i] == candidates[i-1]: continue"
|
||||
|
||||
- title: Forgetting to Sort
|
||||
description: |
|
||||
The duplicate skipping logic relies on duplicates being adjacent. Without sorting, duplicate values could be scattered throughout the array, and the `candidates[i] == candidates[i-1]` check would miss them.
|
||||
|
||||
Always sort the candidates array first before starting the backtracking.
|
||||
wrong_approach: "Attempting duplicate detection without sorting"
|
||||
correct_approach: "Sort candidates first, then use adjacent comparison"
|
||||
|
||||
key_takeaways:
|
||||
- "**Duplicate skipping at same level**: The condition `i > start and candidates[i] == candidates[i-1]` is the key — it prevents duplicate branches at the same decision level while allowing duplicates within a combination"
|
||||
- "**Use once vs. reuse**: The critical difference from Combination Sum I is recursing with `i + 1` instead of `i` to prevent element reuse"
|
||||
- "**Sorting enables everything**: Sorting makes duplicates adjacent for detection and enables early termination pruning"
|
||||
- "**Common pattern**: This duplicate-skipping technique appears in many backtracking problems (Subsets II, Permutations II) — master it once, apply it everywhere"
|
||||
|
||||
time_complexity: "O(2^n). In the worst case, we might explore all possible subsets of the candidates array. The actual complexity is often better due to pruning and the target constraint limiting combination lengths."
|
||||
space_complexity: "O(n). The recursion depth is at most n (the number of candidates), and we use O(n) space for the current combination being built."
|
||||
|
||||
solutions:
|
||||
- approach_name: Backtracking with Duplicate Skipping
|
||||
is_optimal: true
|
||||
code: |
|
||||
def combination_sum2(candidates: list[int], target: int) -> list[list[int]]:
|
||||
result = []
|
||||
# Sort to group duplicates together and enable pruning
|
||||
candidates.sort()
|
||||
|
||||
def backtrack(start: int, remaining: int, current: list[int]) -> None:
|
||||
# Base case: found a valid combination
|
||||
if remaining == 0:
|
||||
result.append(current[:]) # Append a copy
|
||||
return
|
||||
|
||||
for i in range(start, len(candidates)):
|
||||
candidate = candidates[i]
|
||||
|
||||
# Skip duplicates at the same decision level
|
||||
# i > start ensures we don't skip the first occurrence at this level
|
||||
if i > start and candidate == candidates[i - 1]:
|
||||
continue
|
||||
|
||||
# Pruning: if this candidate exceeds remaining, all after will too
|
||||
if candidate > remaining:
|
||||
break
|
||||
|
||||
# Choose: add this candidate
|
||||
current.append(candidate)
|
||||
# Explore: move to next index (each element used once)
|
||||
backtrack(i + 1, remaining - candidate, current)
|
||||
# Unchoose: remove the candidate (backtrack)
|
||||
current.pop()
|
||||
|
||||
backtrack(0, target, [])
|
||||
return result
|
||||
explanation: |
|
||||
**Time Complexity:** O(2^n) — In the worst case, we explore all subsets. Pruning typically reduces this significantly.
|
||||
|
||||
**Space Complexity:** O(n) — Recursion depth is bounded by the number of candidates.
|
||||
|
||||
The key insight is the duplicate-skipping condition `i > start and candidate == candidates[i-1]`. This skips duplicate values only at the same tree level, preventing duplicate combinations while still allowing the same value to appear multiple times in a single combination (using different elements from the input).
|
||||
|
||||
- approach_name: Backtracking with Counter
|
||||
is_optimal: false
|
||||
code: |
|
||||
from collections import Counter
|
||||
|
||||
def combination_sum2(candidates: list[int], target: int) -> list[list[int]]:
|
||||
result = []
|
||||
# Count occurrences of each candidate
|
||||
counter = Counter(candidates)
|
||||
# Get unique candidates sorted
|
||||
unique_candidates = sorted(counter.keys())
|
||||
|
||||
def backtrack(index: int, remaining: int, current: list[int]) -> None:
|
||||
# Base case: found a valid combination
|
||||
if remaining == 0:
|
||||
result.append(current[:])
|
||||
return
|
||||
|
||||
for i in range(index, len(unique_candidates)):
|
||||
candidate = unique_candidates[i]
|
||||
|
||||
# Pruning: if this candidate exceeds remaining, stop
|
||||
if candidate > remaining:
|
||||
break
|
||||
|
||||
# Skip if we've used all occurrences of this candidate
|
||||
if counter[candidate] == 0:
|
||||
continue
|
||||
|
||||
# Choose: use one occurrence of this candidate
|
||||
current.append(candidate)
|
||||
counter[candidate] -= 1
|
||||
|
||||
# Explore: can reuse same index (might have more of this value)
|
||||
backtrack(i, remaining - candidate, current)
|
||||
|
||||
# Unchoose: restore the count
|
||||
current.pop()
|
||||
counter[candidate] += 1
|
||||
|
||||
backtrack(0, target, [])
|
||||
return result
|
||||
explanation: |
|
||||
**Time Complexity:** O(2^n) — Same worst-case as the standard approach.
|
||||
|
||||
**Space Complexity:** O(n) — For the counter and recursion stack.
|
||||
|
||||
This alternative approach uses a Counter to track how many times each unique value can still be used. Instead of skipping duplicates with index comparisons, we naturally handle them by decrementing and checking counts. We stay at the same index (pass `i`) because we might use the same value multiple times if its count allows. This is conceptually cleaner but uses slightly more memory for the counter.
|
||||
|
||||
- approach_name: Iterative with Bitmask
|
||||
is_optimal: false
|
||||
code: |
|
||||
def combination_sum2(candidates: list[int], target: int) -> list[list[int]]:
|
||||
candidates.sort()
|
||||
n = len(candidates)
|
||||
result = []
|
||||
seen = set()
|
||||
|
||||
# Iterate through all possible subsets using bitmask
|
||||
for mask in range(1 << n):
|
||||
subset = []
|
||||
total = 0
|
||||
|
||||
for i in range(n):
|
||||
if mask & (1 << i):
|
||||
subset.append(candidates[i])
|
||||
total += candidates[i]
|
||||
|
||||
# Check if this subset sums to target
|
||||
if total == target:
|
||||
# Convert to tuple for deduplication
|
||||
key = tuple(subset)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
result.append(subset)
|
||||
|
||||
return result
|
||||
explanation: |
|
||||
**Time Complexity:** O(2^n * n) — We enumerate all 2^n subsets and process each in O(n) time.
|
||||
|
||||
**Space Complexity:** O(2^n) — In the worst case, we might store many subsets in the seen set.
|
||||
|
||||
This brute-force approach generates all possible subsets using bit manipulation, filters those that sum to target, and uses a set to eliminate duplicates. While correct, it's inefficient because it doesn't prune impossible paths early and requires extra memory for deduplication. It's included to illustrate why backtracking with pruning is preferred.
|
||||
191
backend/data/questions/combination-sum-iii.yaml
Normal file
191
backend/data/questions/combination-sum-iii.yaml
Normal file
@@ -0,0 +1,191 @@
|
||||
title: Combination Sum III
|
||||
slug: combination-sum-iii
|
||||
difficulty: medium
|
||||
leetcode_id: 216
|
||||
leetcode_url: https://leetcode.com/problems/combination-sum-iii/
|
||||
categories:
|
||||
- arrays
|
||||
- recursion
|
||||
patterns:
|
||||
- backtracking
|
||||
|
||||
description: |
|
||||
Find all valid combinations of `k` numbers that sum up to `n` such that the following conditions are true:
|
||||
|
||||
- Only numbers `1` through `9` are used.
|
||||
- Each number is used **at most once**.
|
||||
|
||||
Return *a list of all possible valid combinations*. The list must not contain the same combination twice, and the combinations may be returned in any order.
|
||||
|
||||
constraints: |
|
||||
- `2 <= k <= 9`
|
||||
- `1 <= n <= 60`
|
||||
|
||||
examples:
|
||||
- input: "k = 3, n = 7"
|
||||
output: "[[1,2,4]]"
|
||||
explanation: "1 + 2 + 4 = 7. There are no other valid combinations."
|
||||
- input: "k = 3, n = 9"
|
||||
output: "[[1,2,6],[1,3,5],[2,3,4]]"
|
||||
explanation: "1 + 2 + 6 = 9, 1 + 3 + 5 = 9, 2 + 3 + 4 = 9. There are no other valid combinations."
|
||||
- input: "k = 4, n = 1"
|
||||
output: "[]"
|
||||
explanation: "There are no valid combinations. Using 4 different numbers in the range [1,9], the smallest sum we can get is 1+2+3+4 = 10, and since 10 > 1, there are no valid combinations."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Imagine you have a row of numbered boxes from 1 to 9, and you need to pick exactly `k` boxes such that their numbers add up to `n`. You can only pick each box once, and order doesn't matter — picking boxes 1, 2, 4 is the same as picking 4, 2, 1.
|
||||
|
||||
This is a classic **combination problem** where you're exploring all possible subsets of a fixed set. The key insight is that you can build combinations incrementally: start with an empty selection, then for each number from 1 to 9, decide whether to include it or skip it.
|
||||
|
||||
Think of it like walking through a decision tree. At each node, you choose to either:
|
||||
- **Include** the current number and move forward
|
||||
- **Skip** the current number and move forward
|
||||
|
||||
When your selection reaches exactly `k` numbers and they sum to `n`, you've found a valid combination. If the sum exceeds `n` or you've used too many numbers, you backtrack and try a different path.
|
||||
|
||||
The constraint that we only use numbers 1-9 keeps the search space small — at most 2<sup>9</sup> = 512 possible subsets — making this problem tractable with backtracking.
|
||||
|
||||
approach: |
|
||||
We solve this using **Backtracking** to explore all valid combinations:
|
||||
|
||||
**Step 1: Set up the recursive function**
|
||||
|
||||
- Create a helper function `backtrack(start, remaining_sum, current_combination)`
|
||||
- `start`: The next number to consider (1 through 9)
|
||||
- `remaining_sum`: How much more we need to reach target `n`
|
||||
- `current_combination`: Numbers we've picked so far
|
||||
|
||||
|
||||
|
||||
**Step 2: Define the base cases**
|
||||
|
||||
- If `current_combination` has exactly `k` numbers AND `remaining_sum == 0`, we found a valid combination — add a copy to results
|
||||
- If `current_combination` has `k` numbers but sum isn't `n`, or if we've exhausted all numbers (start > 9), backtrack
|
||||
|
||||
|
||||
|
||||
**Step 3: Explore choices with pruning**
|
||||
|
||||
- For each number `i` from `start` to 9:
|
||||
- If `i > remaining_sum`, skip it and all larger numbers (pruning)
|
||||
- Otherwise, add `i` to `current_combination`
|
||||
- Recurse with `backtrack(i + 1, remaining_sum - i, current_combination)`
|
||||
- Remove `i` from `current_combination` (backtrack)
|
||||
|
||||
|
||||
|
||||
**Step 4: Start the recursion**
|
||||
|
||||
- Call `backtrack(1, n, [])` and return the collected results
|
||||
|
||||
|
||||
|
||||
The key optimisation is **pruning**: if the current number already exceeds the remaining sum needed, we can skip all larger numbers since they would only increase the overshoot.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Generating Duplicate Combinations
|
||||
description: |
|
||||
Without careful ordering, you might generate the same combination multiple times. For example, [1,2,4] and [2,1,4] are the same combination.
|
||||
|
||||
The fix is to always process numbers in increasing order by only considering numbers greater than or equal to `start`. This ensures each combination is generated exactly once in sorted order.
|
||||
wrong_approach: "Consider all numbers 1-9 at each step"
|
||||
correct_approach: "Only consider numbers from `start` to 9"
|
||||
|
||||
- title: Forgetting to Backtrack
|
||||
description: |
|
||||
After exploring a path that includes a number, you must remove that number before exploring paths that exclude it. Forgetting this step corrupts the `current_combination` list.
|
||||
|
||||
Always pair "add to combination" with "remove from combination" after the recursive call returns.
|
||||
wrong_approach: "Add number but never remove it"
|
||||
correct_approach: "Remove the number after recursive call (backtrack)"
|
||||
|
||||
- title: Not Copying the Combination When Adding to Results
|
||||
description: |
|
||||
In Python, lists are mutable. If you append `current_combination` directly to results, all entries will reference the same list, which gets modified during backtracking.
|
||||
|
||||
Always append a copy: `results.append(current_combination.copy())` or `results.append(list(current_combination))`.
|
||||
wrong_approach: "results.append(current_combination)"
|
||||
correct_approach: "results.append(current_combination.copy())"
|
||||
|
||||
- title: Missing the Pruning Optimisation
|
||||
description: |
|
||||
Without pruning, you explore branches that can never lead to a valid solution. For instance, if you need `remaining_sum = 3` and you're at number 5, there's no point continuing since 5 > 3 and all subsequent numbers are even larger.
|
||||
|
||||
Adding `if i > remaining_sum: break` significantly reduces unnecessary exploration.
|
||||
|
||||
key_takeaways:
|
||||
- "**Backtracking template**: This problem follows the classic backtracking pattern — make a choice, recurse, undo the choice"
|
||||
- "**Avoid duplicates by ordering**: Processing elements in sorted order and only considering elements >= start prevents generating the same combination twice"
|
||||
- "**Prune aggressively**: Early termination when a branch cannot lead to a valid solution dramatically improves performance"
|
||||
- "**Foundation for harder problems**: This pattern extends to Combination Sum I, II, IV, and other subset/permutation problems"
|
||||
|
||||
time_complexity: "O(C(9, k) * k). We explore at most C(9, k) combinations (9 choose k), and each valid combination takes O(k) time to copy. Since k <= 9 and C(9, k) <= 126, this is effectively constant for this problem."
|
||||
space_complexity: "O(k). The recursion depth is at most k, and we use O(k) space for the current combination. The output space for storing results is not counted."
|
||||
|
||||
solutions:
|
||||
- approach_name: Backtracking with Pruning
|
||||
is_optimal: true
|
||||
code: |
|
||||
def combination_sum3(k: int, n: int) -> list[list[int]]:
|
||||
results = []
|
||||
|
||||
def backtrack(start: int, remaining: int, combination: list[int]) -> None:
|
||||
# Found a valid combination
|
||||
if len(combination) == k and remaining == 0:
|
||||
results.append(combination.copy())
|
||||
return
|
||||
|
||||
# Too many numbers or exhausted search space
|
||||
if len(combination) == k or start > 9:
|
||||
return
|
||||
|
||||
# Try each number from start to 9
|
||||
for num in range(start, 10):
|
||||
# Pruning: if current number exceeds remaining sum, skip rest
|
||||
if num > remaining:
|
||||
break
|
||||
|
||||
# Include this number and recurse
|
||||
combination.append(num)
|
||||
backtrack(num + 1, remaining - num, combination)
|
||||
# Backtrack: remove the number we just added
|
||||
combination.pop()
|
||||
|
||||
backtrack(1, n, [])
|
||||
return results
|
||||
explanation: |
|
||||
**Time Complexity:** O(C(9, k) * k) — We explore combinations of k numbers from 1-9, copying each valid one.
|
||||
|
||||
**Space Complexity:** O(k) — Recursion depth and combination list are bounded by k.
|
||||
|
||||
The backtracking explores the decision tree of including/excluding each number. Pruning when `num > remaining` cuts off entire subtrees, and processing numbers in order prevents duplicates.
|
||||
|
||||
- approach_name: Iterative with Bitmask
|
||||
is_optimal: false
|
||||
code: |
|
||||
def combination_sum3(k: int, n: int) -> list[list[int]]:
|
||||
results = []
|
||||
|
||||
# Iterate through all 2^9 = 512 subsets
|
||||
for mask in range(1, 1 << 9):
|
||||
combination = []
|
||||
total = 0
|
||||
|
||||
# Check which bits are set (which numbers to include)
|
||||
for i in range(9):
|
||||
if mask & (1 << i):
|
||||
combination.append(i + 1) # Numbers are 1-indexed
|
||||
total += i + 1
|
||||
|
||||
# Check if this subset matches our criteria
|
||||
if len(combination) == k and total == n:
|
||||
results.append(combination)
|
||||
|
||||
return results
|
||||
explanation: |
|
||||
**Time Complexity:** O(2^9 * 9) = O(4608) — Check all 512 subsets, each taking O(9) to process.
|
||||
|
||||
**Space Complexity:** O(k) — Each combination uses O(k) space.
|
||||
|
||||
This approach treats each subset as a bitmask where bit i indicates whether number (i+1) is included. While less elegant than backtracking, it's simple and the small search space (512 subsets) makes it practical. No pruning is applied, so it explores all subsets regardless of validity.
|
||||
173
backend/data/questions/combination-sum-iv.yaml
Normal file
173
backend/data/questions/combination-sum-iv.yaml
Normal file
@@ -0,0 +1,173 @@
|
||||
title: Combination Sum IV
|
||||
slug: combination-sum-iv
|
||||
difficulty: medium
|
||||
leetcode_id: 377
|
||||
leetcode_url: https://leetcode.com/problems/combination-sum-iv/
|
||||
categories:
|
||||
- dynamic-programming
|
||||
- arrays
|
||||
patterns:
|
||||
- dynamic-programming
|
||||
|
||||
description: |
|
||||
Given an array of **distinct** integers `nums` and a target integer `target`, return *the number of possible combinations that add up to* `target`.
|
||||
|
||||
The test cases are generated so that the answer can fit in a **32-bit** integer.
|
||||
|
||||
**Note:** Different sequences are counted as different combinations. For example, `(1, 1, 2)` and `(1, 2, 1)` and `(2, 1, 1)` are all counted separately.
|
||||
|
||||
constraints: |
|
||||
- `1 <= nums.length <= 200`
|
||||
- `1 <= nums[i] <= 1000`
|
||||
- All the elements of `nums` are **unique**
|
||||
- `1 <= target <= 1000`
|
||||
|
||||
examples:
|
||||
- input: "nums = [1,2,3], target = 4"
|
||||
output: "7"
|
||||
explanation: "The 7 possible combination ways are: (1,1,1,1), (1,1,2), (1,2,1), (1,3), (2,1,1), (2,2), (3,1). Note that different sequences are counted as different combinations."
|
||||
- input: "nums = [9], target = 3"
|
||||
output: "0"
|
||||
explanation: "No combination of 9s can sum to 3, so return 0."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Despite its name, this problem is actually counting **permutations**, not combinations — because the order matters! `(1, 2, 1)` and `(1, 1, 2)` are counted as different sequences.
|
||||
|
||||
Think of it like climbing stairs where each step can be any number in `nums`. If you're at stair 0 and want to reach stair 4, how many distinct paths are there? At each position, you can jump by any value in `nums`, and the same jump sequence in different orders counts as different paths.
|
||||
|
||||
The key insight is: to count paths to reach `target`, sum up all paths to positions you could have jumped *from*. If `nums = [1, 2, 3]` and you want to reach 4:
|
||||
- You could arrive from position 3 (jump +1)
|
||||
- You could arrive from position 2 (jump +2)
|
||||
- You could arrive from position 1 (jump +3)
|
||||
|
||||
So: `ways(4) = ways(3) + ways(2) + ways(1)`. This is the **counting DP** pattern applied to permutations.
|
||||
|
||||
approach: |
|
||||
We solve this using **Bottom-Up Dynamic Programming**:
|
||||
|
||||
**Step 1: Create and initialise the DP array**
|
||||
|
||||
- Create `dp` of size `target + 1`, where `dp[i]` = number of ways to reach sum `i`
|
||||
- Set `dp[0] = 1` as the base case: exactly one way to make sum 0 (use no numbers)
|
||||
- All other entries start at 0 (no ways discovered yet)
|
||||
|
||||
|
||||
|
||||
**Step 2: Build up solutions for each target value**
|
||||
|
||||
- For each sum `i` from 1 to `target`:
|
||||
- For each number `num` in `nums`:
|
||||
- If `num <= i` (the number doesn't exceed our current target):
|
||||
- Add `dp[i - num]` to `dp[i]`
|
||||
- This counts: "ways to reach `i` by using `num` as the last element"
|
||||
- The total `dp[i]` accumulates ways from all possible last elements
|
||||
|
||||
|
||||
|
||||
**Step 3: Return the answer**
|
||||
|
||||
- Return `dp[target]` — the total number of sequences summing to target
|
||||
|
||||
|
||||
|
||||
The order of loops matters! By iterating over sums first (outer) and nums second (inner), we count each ordering separately. This gives us permutations, not combinations.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Confusing with Coin Change Combinations
|
||||
description: |
|
||||
In classic "coin change counting" problems, order doesn't matter: `{1, 1, 2}` and `{1, 2, 1}` are the same combination. For those, you iterate over coins in the outer loop and amounts in the inner loop.
|
||||
|
||||
Here, order matters! The loop order is flipped: amounts outer, numbers inner. This ensures we count `(1, 1, 2)`, `(1, 2, 1)`, and `(2, 1, 1)` as three distinct sequences.
|
||||
|
||||
**Coin change (combinations):** `for coin in coins: for i in range(...)`
|
||||
**This problem (permutations):** `for i in range(...): for num in nums`
|
||||
wrong_approach: "Using coin-change loop order (coins outer, amounts inner)"
|
||||
correct_approach: "Amounts outer, numbers inner to count orderings"
|
||||
|
||||
- title: Wrong Base Case
|
||||
description: |
|
||||
The base case `dp[0] = 1` is essential. It represents that there's exactly one way to reach sum 0: by choosing nothing.
|
||||
|
||||
If you set `dp[0] = 0`, all subsequent values would be 0 because you'd have no starting point for the recurrence.
|
||||
wrong_approach: "dp[0] = 0 or leaving it unset"
|
||||
correct_approach: "dp[0] = 1 to bootstrap the recurrence"
|
||||
|
||||
- title: Recursion Without Memoisation
|
||||
description: |
|
||||
A naive recursive solution would recompute the same subproblems exponentially many times. For `nums = [1, 2, 3]` and `target = 100`, pure recursion would timeout.
|
||||
|
||||
Either use top-down with memoisation, or bottom-up DP. Both achieve O(target × n) time complexity.
|
||||
wrong_approach: "Pure recursion: return sum(count(target - num) for num in nums)"
|
||||
correct_approach: "Memoisation or bottom-up DP to cache subproblem results"
|
||||
|
||||
key_takeaways:
|
||||
- "**Loop order determines counting type**: Amounts-first counts permutations (order matters); items-first counts combinations (order ignored)"
|
||||
- "**Permutation counting via DP**: Sum the ways to reach all positions you could have come *from*"
|
||||
- "**Related to stair climbing**: This is essentially \"climb stairs\" with variable step sizes"
|
||||
- "**Foundation for string problems**: Same pattern applies to decode ways, word break counting, etc."
|
||||
|
||||
time_complexity: "O(target × n). For each value from 1 to target, we iterate through all n numbers in the array."
|
||||
space_complexity: "O(target). The DP array stores one count per sum from 0 to target."
|
||||
|
||||
solutions:
|
||||
- approach_name: Bottom-Up DP
|
||||
is_optimal: true
|
||||
code: |
|
||||
def combination_sum4(nums: list[int], target: int) -> int:
|
||||
# dp[i] = number of ways to form sum i
|
||||
dp = [0] * (target + 1)
|
||||
|
||||
# Base case: one way to make sum 0 (use nothing)
|
||||
dp[0] = 1
|
||||
|
||||
# For each target sum, count ways to reach it
|
||||
for i in range(1, target + 1):
|
||||
# Try each number as the last element in the sequence
|
||||
for num in nums:
|
||||
# If this number can contribute to sum i
|
||||
if num <= i:
|
||||
# Add all ways to form the remaining sum
|
||||
dp[i] += dp[i - num]
|
||||
|
||||
return dp[target]
|
||||
explanation: |
|
||||
**Time Complexity:** O(target × n) — Nested loops over target values and numbers.
|
||||
|
||||
**Space Complexity:** O(target) — DP array of size `target + 1`.
|
||||
|
||||
We build up counts from sum 0. For each sum `i`, we ask: "How many ways can I reach `i` by adding some number from `nums` to a smaller sum?" By summing `dp[i - num]` for all valid `num`, we count every possible sequence ending with that number.
|
||||
|
||||
- approach_name: Top-Down DP (Memoisation)
|
||||
is_optimal: true
|
||||
code: |
|
||||
def combination_sum4(nums: list[int], target: int) -> int:
|
||||
# Cache for memoisation
|
||||
memo = {}
|
||||
|
||||
def count(remaining: int) -> int:
|
||||
# Base case: found a valid sequence
|
||||
if remaining == 0:
|
||||
return 1
|
||||
|
||||
# Check cache
|
||||
if remaining in memo:
|
||||
return memo[remaining]
|
||||
|
||||
# Try each number as the next element
|
||||
total = 0
|
||||
for num in nums:
|
||||
if num <= remaining:
|
||||
total += count(remaining - num)
|
||||
|
||||
# Cache and return
|
||||
memo[remaining] = total
|
||||
return total
|
||||
|
||||
return count(target)
|
||||
explanation: |
|
||||
**Time Complexity:** O(target × n) — Each subproblem (0 to target) computed once, each checking n numbers.
|
||||
|
||||
**Space Complexity:** O(target) — Memoisation cache plus recursion stack.
|
||||
|
||||
This top-down approach is functionally equivalent to bottom-up. We recursively count ways to reduce `remaining` to 0, caching results to avoid recomputation. Some find this more intuitive as it directly mirrors the recurrence relation.
|
||||
237
backend/data/questions/combination-sum.yaml
Normal file
237
backend/data/questions/combination-sum.yaml
Normal file
@@ -0,0 +1,237 @@
|
||||
title: Combination Sum
|
||||
slug: combination-sum
|
||||
difficulty: medium
|
||||
leetcode_id: 39
|
||||
leetcode_url: https://leetcode.com/problems/combination-sum/
|
||||
categories:
|
||||
- arrays
|
||||
- recursion
|
||||
patterns:
|
||||
- backtracking
|
||||
|
||||
description: |
|
||||
Given an array of **distinct** integers `candidates` and a target integer `target`, return *a list of all **unique combinations** of* `candidates` *where the chosen numbers sum to* `target`. You may return the combinations in **any order**.
|
||||
|
||||
The **same** number may be chosen from `candidates` an **unlimited number of times**. Two combinations are unique if the frequency of at least one of the chosen numbers is different.
|
||||
|
||||
The test cases are generated such that the number of unique combinations that sum up to `target` is less than `150` combinations for the given input.
|
||||
|
||||
constraints: |
|
||||
- `1 <= candidates.length <= 30`
|
||||
- `2 <= candidates[i] <= 40`
|
||||
- All elements of `candidates` are **distinct**
|
||||
- `1 <= target <= 40`
|
||||
|
||||
examples:
|
||||
- input: "candidates = [2,3,6,7], target = 7"
|
||||
output: "[[2,2,3],[7]]"
|
||||
explanation: "2 and 3 are candidates, and 2 + 2 + 3 = 7. Note that 2 can be used multiple times. 7 is a candidate, and 7 = 7. These are the only two combinations."
|
||||
- input: "candidates = [2,3,5], target = 8"
|
||||
output: "[[2,2,2,2],[2,3,3],[3,5]]"
|
||||
explanation: "The three combinations that sum to 8 are: four 2s, two 2s and two 3s, and one 3 with one 5."
|
||||
- input: "candidates = [2], target = 1"
|
||||
output: "[]"
|
||||
explanation: "The smallest candidate is 2, which is already larger than the target 1, so no combination is possible."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Imagine you're at a vending machine that only accepts exact change. You have an unlimited supply of certain coin denominations (the candidates), and you need to find every possible way to make exactly the target amount.
|
||||
|
||||
The key insight is that this is a **decision tree** problem. At each step, you decide: "Should I use another coin of this denomination, or move on to the next denomination?" By exploring all paths through this decision tree, you find all valid combinations.
|
||||
|
||||
Think of it like filling a shopping cart: for each item type (candidate), you can take zero, one, two, or more of that item. But you have a budget (target), and you need to find all ways to spend *exactly* that budget.
|
||||
|
||||
What makes this different from standard combinations is that we can **reuse the same element**. This means when we pick a candidate, we don't move past it — we stay and consider picking it again. We only move to the next candidate when we decide we're done with the current one.
|
||||
|
||||
The backtracking approach efficiently explores this space by building combinations incrementally, abandoning paths as soon as they exceed the target (pruning), and trying all possibilities through the choose-explore-unchoose pattern.
|
||||
|
||||
approach: |
|
||||
We solve this using **Backtracking with Pruning**:
|
||||
|
||||
**Step 1: Sort the candidates (optional but enables better pruning)**
|
||||
|
||||
- Sorting allows us to stop early when a candidate exceeds the remaining target
|
||||
- If `candidates[i] > remaining`, all subsequent candidates will also exceed it
|
||||
|
||||
|
||||
|
||||
**Step 2: Define the recursive backtrack function**
|
||||
|
||||
- `backtrack(start, remaining, current_combination)`
|
||||
- `start`: index of the first candidate we can use (prevents duplicates)
|
||||
- `remaining`: how much more we need to reach the target
|
||||
- `current_combination`: the combination being built
|
||||
|
||||
|
||||
|
||||
**Step 3: Base case — target reached**
|
||||
|
||||
- If `remaining == 0`, we've found a valid combination
|
||||
- Add a **copy** of `current_combination` to results
|
||||
- Return to explore other paths
|
||||
|
||||
|
||||
|
||||
**Step 4: Recursive case — try each candidate**
|
||||
|
||||
- Loop through candidates starting from index `start`
|
||||
- For each candidate at index `i`:
|
||||
- **Prune**: If `candidates[i] > remaining`, break (no point trying larger candidates)
|
||||
- **Choose**: Add `candidates[i]` to `current_combination`
|
||||
- **Explore**: Recursively call `backtrack(i, remaining - candidates[i], current_combination)`
|
||||
- Note: we pass `i`, not `i + 1`, because we can reuse the same candidate
|
||||
- **Unchoose**: Remove the last element (backtrack)
|
||||
|
||||
|
||||
|
||||
**Step 5: Return all valid combinations**
|
||||
|
||||
- Call `backtrack(0, target, [])` to start the search
|
||||
- Return the collected results
|
||||
|
||||
common_pitfalls:
|
||||
- title: Generating Duplicate Combinations
|
||||
description: |
|
||||
Without careful indexing, you might generate `[2,3,2]` and `[2,2,3]` and `[3,2,2]` as separate combinations when they should all be the same.
|
||||
|
||||
The fix is to **only consider candidates at or after the current index**. By always building combinations in a consistent order (never going backwards in the candidates array), each unique multiset appears exactly once.
|
||||
|
||||
For example, with candidates `[2,3]` and target 7:
|
||||
- We explore `[2,2,2,...]` before `[2,3,...]` before `[3,...]`
|
||||
- We never generate `[3,2,...]` because we don't go back to index 0 after processing index 1
|
||||
wrong_approach: "Starting each recursion from index 0"
|
||||
correct_approach: "Starting from the current candidate's index"
|
||||
|
||||
- title: Moving Past the Current Candidate Too Early
|
||||
description: |
|
||||
In standard combination problems, after picking element at index `i`, we recurse with `i + 1`. But here, we can reuse elements!
|
||||
|
||||
If you recurse with `i + 1` instead of `i`, you'll miss combinations like `[2,2,3]` because after picking the first 2, you'd move to 3 and never pick another 2.
|
||||
|
||||
The key difference: pass `i` (same index) to allow reuse, not `i + 1`.
|
||||
wrong_approach: "backtrack(i + 1, ...) after choosing candidates[i]"
|
||||
correct_approach: "backtrack(i, ...) to allow reusing the same candidate"
|
||||
|
||||
- title: Not Pruning When Exceeding Target
|
||||
description: |
|
||||
Without pruning, the algorithm wastes time exploring paths that already exceed the target. For example, if `remaining = 3` and `candidates[i] = 5`, there's no point adding 5 or any larger candidate.
|
||||
|
||||
Sorting candidates first enables early termination: once a candidate exceeds `remaining`, all subsequent candidates (being equal or larger) will also exceed it, so we can `break` out of the loop entirely.
|
||||
wrong_approach: "Continuing to try all candidates regardless of remaining target"
|
||||
correct_approach: "Breaking early when candidate > remaining (after sorting)"
|
||||
|
||||
- title: Forgetting to Copy the Combination
|
||||
description: |
|
||||
A classic backtracking bug: appending `current_combination` directly to results instead of a copy.
|
||||
|
||||
Since `current_combination` is modified during backtracking (elements added and removed), all entries in results would reference the same list, which ends up empty.
|
||||
|
||||
Always append a copy: `results.append(current_combination[:])` or `results.append(list(current_combination))`.
|
||||
wrong_approach: "results.append(current_combination)"
|
||||
correct_approach: "results.append(current_combination[:])"
|
||||
|
||||
key_takeaways:
|
||||
- "**Reuse vs. no-reuse**: The key difference from standard combinations is passing `i` instead of `i + 1` in the recursive call, allowing unlimited reuse of each candidate"
|
||||
- "**Pruning with sorting**: Sorting candidates enables early termination when a candidate exceeds the remaining target, significantly improving performance"
|
||||
- "**Avoiding duplicates through ordering**: By only considering candidates at index >= current index, we ensure each combination is generated exactly once"
|
||||
- "**Foundation for variants**: This pattern extends to Combination Sum II (each element used once), III (exactly k numbers), and IV (count combinations)"
|
||||
|
||||
time_complexity: "O(n^(t/m)). In the worst case, we explore a tree where each node has n branches, and the depth is t/m (target divided by minimum candidate). The actual complexity depends heavily on the input — with good pruning, it's often much faster."
|
||||
space_complexity: "O(t/m). The recursion depth is bounded by target/min_candidate (the maximum number of elements in any combination). We also use O(t/m) space for the current combination being built."
|
||||
|
||||
solutions:
|
||||
- approach_name: Backtracking with Pruning
|
||||
is_optimal: true
|
||||
code: |
|
||||
def combination_sum(candidates: list[int], target: int) -> list[list[int]]:
|
||||
result = []
|
||||
# Sort to enable pruning: once a candidate exceeds remaining, all after it will too
|
||||
candidates.sort()
|
||||
|
||||
def backtrack(start: int, remaining: int, current: list[int]) -> None:
|
||||
# Base case: found a valid combination
|
||||
if remaining == 0:
|
||||
result.append(current[:]) # Append a copy
|
||||
return
|
||||
|
||||
# Try each candidate starting from 'start' index
|
||||
for i in range(start, len(candidates)):
|
||||
candidate = candidates[i]
|
||||
|
||||
# Pruning: if this candidate exceeds remaining, all after it will too
|
||||
if candidate > remaining:
|
||||
break
|
||||
|
||||
# Choose: add this candidate to our combination
|
||||
current.append(candidate)
|
||||
# Explore: recurse with same index (can reuse this candidate)
|
||||
backtrack(i, remaining - candidate, current)
|
||||
# Unchoose: remove the candidate (backtrack)
|
||||
current.pop()
|
||||
|
||||
backtrack(0, target, [])
|
||||
return result
|
||||
explanation: |
|
||||
**Time Complexity:** O(n^(t/m)) — Where n is the number of candidates, t is the target, and m is the minimum candidate value. This represents the worst-case branching factor and depth.
|
||||
|
||||
**Space Complexity:** O(t/m) — The maximum recursion depth equals the maximum combination length.
|
||||
|
||||
The backtracking approach systematically explores all valid combinations. Sorting enables powerful pruning: once a candidate exceeds the remaining target, we skip all larger candidates. The key insight is passing the same index `i` (not `i + 1`) to allow unlimited reuse of each candidate.
|
||||
|
||||
- approach_name: Backtracking without Sorting
|
||||
is_optimal: false
|
||||
code: |
|
||||
def combination_sum(candidates: list[int], target: int) -> list[list[int]]:
|
||||
result = []
|
||||
|
||||
def backtrack(start: int, remaining: int, current: list[int]) -> None:
|
||||
# Base case: found a valid combination
|
||||
if remaining == 0:
|
||||
result.append(current[:])
|
||||
return
|
||||
|
||||
# Base case: exceeded target, abandon this path
|
||||
if remaining < 0:
|
||||
return
|
||||
|
||||
# Try each candidate starting from 'start' index
|
||||
for i in range(start, len(candidates)):
|
||||
# Choose
|
||||
current.append(candidates[i])
|
||||
# Explore with same index (can reuse)
|
||||
backtrack(i, remaining - candidates[i], current)
|
||||
# Unchoose
|
||||
current.pop()
|
||||
|
||||
backtrack(0, target, [])
|
||||
return result
|
||||
explanation: |
|
||||
**Time Complexity:** O(n^(t/m)) — Same worst case as the optimised version.
|
||||
|
||||
**Space Complexity:** O(t/m) — Same recursion depth bound.
|
||||
|
||||
This version works without sorting but is less efficient. Instead of breaking early when a candidate exceeds the remaining target, it continues and relies on the `remaining < 0` check to prune. This means it explores more invalid branches before abandoning them. For small inputs the difference is negligible, but sorting provides a meaningful speedup for larger cases.
|
||||
|
||||
- approach_name: Dynamic Programming
|
||||
is_optimal: false
|
||||
code: |
|
||||
def combination_sum(candidates: list[int], target: int) -> list[list[int]]:
|
||||
# dp[i] contains all combinations that sum to i
|
||||
dp = [[] for _ in range(target + 1)]
|
||||
dp[0] = [[]] # One way to make sum 0: empty combination
|
||||
|
||||
for candidate in candidates:
|
||||
# For each sum from candidate to target
|
||||
for current_sum in range(candidate, target + 1):
|
||||
# Extend each combination that sums to (current_sum - candidate)
|
||||
for combo in dp[current_sum - candidate]:
|
||||
# Add this candidate to form a combination summing to current_sum
|
||||
dp[current_sum].append(combo + [candidate])
|
||||
|
||||
return dp[target]
|
||||
explanation: |
|
||||
**Time Complexity:** O(n * t * k) — Where n is the number of candidates, t is the target, and k is the average number of combinations at each sum.
|
||||
|
||||
**Space Complexity:** O(t * k * m) — We store all combinations for each sum up to target, where m is the average combination length.
|
||||
|
||||
This bottom-up DP approach builds combinations incrementally. For each candidate, we iterate through all possible sums and extend existing combinations. By processing candidates in order and only extending existing combinations, we naturally avoid duplicates. However, this approach uses more memory than backtracking since it stores all intermediate combinations.
|
||||
187
backend/data/questions/combinations.yaml
Normal file
187
backend/data/questions/combinations.yaml
Normal file
@@ -0,0 +1,187 @@
|
||||
title: Combinations
|
||||
slug: combinations
|
||||
difficulty: medium
|
||||
leetcode_id: 77
|
||||
leetcode_url: https://leetcode.com/problems/combinations/
|
||||
categories:
|
||||
- arrays
|
||||
- recursion
|
||||
patterns:
|
||||
- backtracking
|
||||
|
||||
description: |
|
||||
Given two integers `n` and `k`, return *all possible combinations of* `k` *numbers chosen from the range* `[1, n]`.
|
||||
|
||||
You may return the answer in **any order**.
|
||||
|
||||
constraints: |
|
||||
- `1 <= n <= 20`
|
||||
- `1 <= k <= n`
|
||||
|
||||
examples:
|
||||
- input: "n = 4, k = 2"
|
||||
output: "[[1,2],[1,3],[1,4],[2,3],[2,4],[3,4]]"
|
||||
explanation: "There are 4 choose 2 = 6 total combinations. Note that combinations are unordered, i.e., [1,2] and [2,1] are considered to be the same combination."
|
||||
- input: "n = 1, k = 1"
|
||||
output: "[[1]]"
|
||||
explanation: "There is 1 choose 1 = 1 total combination."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Imagine you're picking a team of `k` players from a pool of `n` candidates numbered 1 through `n`. The order you pick them doesn't matter — selecting player 1 then player 3 is the same team as selecting player 3 then player 1.
|
||||
|
||||
The key insight is that we can **build combinations incrementally** by making a series of choices. At each step, we decide: "Should I include this number in my combination?" If we include it, we move on to consider the next number. If not, we skip it and move to the next.
|
||||
|
||||
To avoid duplicates like `[1,3]` and `[3,1]`, we enforce a rule: **only consider numbers greater than the last number we picked**. This ensures each combination is built in ascending order, so `[1,3]` is generated but `[3,1]` never is.
|
||||
|
||||
This naturally leads to a *backtracking* approach: we explore one path (include a number), and if it doesn't lead to a valid combination of size `k`, we "backtrack" by removing that number and trying the next option.
|
||||
|
||||
approach: |
|
||||
We solve this using **Backtracking**:
|
||||
|
||||
**Step 1: Define the recursive function**
|
||||
|
||||
- Create a helper function `backtrack(start, current_combination)` that builds combinations
|
||||
- `start`: the smallest number we can still pick (ensures ascending order)
|
||||
- `current_combination`: the combination being built
|
||||
|
||||
|
||||
|
||||
**Step 2: Base case — combination is complete**
|
||||
|
||||
- If `len(current_combination) == k`, we've found a valid combination
|
||||
- Add a **copy** of `current_combination` to results (important: we need a copy because we'll modify it later)
|
||||
- Return to explore other paths
|
||||
|
||||
|
||||
|
||||
**Step 3: Recursive case — try each remaining number**
|
||||
|
||||
- Loop through numbers from `start` to `n`
|
||||
- For each number `i`:
|
||||
- **Choose**: Add `i` to `current_combination`
|
||||
- **Explore**: Recursively call `backtrack(i + 1, current_combination)`
|
||||
- **Unchoose**: Remove `i` from `current_combination` (backtrack)
|
||||
|
||||
|
||||
|
||||
**Step 4: Optimisation — pruning**
|
||||
|
||||
- If there aren't enough numbers left to complete a combination, stop early
|
||||
- We need `k - len(current_combination)` more numbers
|
||||
- We have `n - start + 1` numbers remaining
|
||||
- If remaining < needed, prune this branch
|
||||
|
||||
|
||||
|
||||
**Step 5: Return all combinations**
|
||||
|
||||
- Call `backtrack(1, [])` to start building from number 1
|
||||
- Return the collected results
|
||||
|
||||
common_pitfalls:
|
||||
- title: Generating Duplicate Combinations
|
||||
description: |
|
||||
Without enforcing order, you might generate both `[1,3]` and `[3,1]`. Since combinations are unordered, these are duplicates.
|
||||
|
||||
The fix is to only consider numbers **greater than** the last number added. By always building in ascending order, each unique set of numbers appears exactly once.
|
||||
wrong_approach: "Starting each recursion from 1"
|
||||
correct_approach: "Starting from last_picked + 1"
|
||||
|
||||
- title: Forgetting to Copy the Combination
|
||||
description: |
|
||||
A common bug is adding `current_combination` directly to results:
|
||||
```python
|
||||
result.append(current_combination) # Bug!
|
||||
```
|
||||
|
||||
Since `current_combination` is modified during backtracking, all entries in `result` end up pointing to the same (eventually empty) list.
|
||||
|
||||
Always append a **copy**: `result.append(current_combination[:])` or `result.append(list(current_combination))`.
|
||||
wrong_approach: "result.append(current_combination)"
|
||||
correct_approach: "result.append(current_combination[:])"
|
||||
|
||||
- title: Missing the Pruning Optimisation
|
||||
description: |
|
||||
Without pruning, the algorithm explores paths that can never lead to valid combinations. For example, if `n = 4`, `k = 3`, and we've picked `[4]`, there's no way to pick 2 more numbers greater than 4.
|
||||
|
||||
Adding a check at the start of the loop — `if n - i + 1 < k - len(current)` — skips these futile branches and significantly speeds up execution.
|
||||
wrong_approach: "Exploring all paths regardless of remaining elements"
|
||||
correct_approach: "Pruning when remaining elements < needed elements"
|
||||
|
||||
key_takeaways:
|
||||
- "**Backtracking template**: The choose-explore-unchoose pattern is the foundation for generating all permutations, combinations, and subsets"
|
||||
- "**Avoiding duplicates**: Enforcing an ordering (only picking larger numbers) is a common technique to prevent duplicate combinations"
|
||||
- "**Pruning**: Early termination when a path cannot lead to a solution dramatically improves performance"
|
||||
- "**Copy before storing**: When collecting mutable objects during recursion, always store copies, not references"
|
||||
|
||||
time_complexity: "O(k * C(n,k)). We generate C(n,k) combinations, and copying each combination of length k takes O(k) time."
|
||||
space_complexity: "O(k). The recursion depth is at most k (the size of each combination), and we use O(k) space for the current combination being built. The output space O(k * C(n,k)) is not counted as auxiliary space."
|
||||
|
||||
solutions:
|
||||
- approach_name: Backtracking
|
||||
is_optimal: true
|
||||
code: |
|
||||
def combine(n: int, k: int) -> list[list[int]]:
|
||||
result = []
|
||||
|
||||
def backtrack(start: int, current: list[int]) -> None:
|
||||
# Base case: combination is complete
|
||||
if len(current) == k:
|
||||
result.append(current[:]) # Append a copy
|
||||
return
|
||||
|
||||
# Try each number from start to n
|
||||
for i in range(start, n + 1):
|
||||
# Pruning: not enough numbers left to complete combination
|
||||
if n - i + 1 < k - len(current):
|
||||
break
|
||||
|
||||
# Choose: add current number
|
||||
current.append(i)
|
||||
# Explore: recurse with next starting point
|
||||
backtrack(i + 1, current)
|
||||
# Unchoose: remove current number (backtrack)
|
||||
current.pop()
|
||||
|
||||
backtrack(1, [])
|
||||
return result
|
||||
explanation: |
|
||||
**Time Complexity:** O(k * C(n,k)) — We generate all C(n,k) combinations, each of length k.
|
||||
|
||||
**Space Complexity:** O(k) — Recursion depth and current combination size are bounded by k.
|
||||
|
||||
The backtracking approach systematically explores all valid combinations by making choices, recursing, and undoing choices. Pruning ensures we skip branches that cannot yield valid combinations.
|
||||
|
||||
- approach_name: Iterative with Lexicographic Generation
|
||||
is_optimal: false
|
||||
code: |
|
||||
def combine(n: int, k: int) -> list[list[int]]:
|
||||
result = []
|
||||
# Start with the lexicographically smallest combination
|
||||
current = list(range(1, k + 1))
|
||||
|
||||
while True:
|
||||
result.append(current[:])
|
||||
|
||||
# Find the rightmost element that can be incremented
|
||||
i = k - 1
|
||||
while i >= 0 and current[i] == n - k + 1 + i:
|
||||
i -= 1
|
||||
|
||||
# If no such element exists, we've generated all combinations
|
||||
if i < 0:
|
||||
break
|
||||
|
||||
# Increment this element and reset all elements to its right
|
||||
current[i] += 1
|
||||
for j in range(i + 1, k):
|
||||
current[j] = current[j - 1] + 1
|
||||
|
||||
return result
|
||||
explanation: |
|
||||
**Time Complexity:** O(k * C(n,k)) — Same as backtracking, we generate all combinations.
|
||||
|
||||
**Space Complexity:** O(k) — Only the current combination is stored.
|
||||
|
||||
This approach generates combinations in lexicographic order without recursion. It starts with `[1, 2, ..., k]` and repeatedly finds the rightmost element that can be incremented, then resets all elements after it. More memory-efficient than recursion but harder to understand.
|
||||
160
backend/data/questions/concatenation-of-array.yaml
Normal file
160
backend/data/questions/concatenation-of-array.yaml
Normal file
@@ -0,0 +1,160 @@
|
||||
title: Concatenation of Array
|
||||
slug: concatenation-of-array
|
||||
difficulty: easy
|
||||
leetcode_id: 1929
|
||||
leetcode_url: https://leetcode.com/problems/concatenation-of-array/
|
||||
categories:
|
||||
- arrays
|
||||
patterns:
|
||||
- two-pointers
|
||||
|
||||
description: |
|
||||
Given an integer array `nums` of length `n`, you want to create an array `ans` of length `2n` where `ans[i] == nums[i]` and `ans[i + n] == nums[i]` for `0 <= i < n` (**0-indexed**).
|
||||
|
||||
Specifically, `ans` is the **concatenation** of two `nums` arrays.
|
||||
|
||||
Return *the array* `ans`.
|
||||
|
||||
constraints: |
|
||||
- `n == nums.length`
|
||||
- `1 <= n <= 1000`
|
||||
- `1 <= nums[i] <= 1000`
|
||||
|
||||
examples:
|
||||
- input: "nums = [1,2,1]"
|
||||
output: "[1,2,1,1,2,1]"
|
||||
explanation: "The array ans is formed as follows: ans = [nums[0],nums[1],nums[2],nums[0],nums[1],nums[2]] = [1,2,1,1,2,1]"
|
||||
- input: "nums = [1,3,2,1]"
|
||||
output: "[1,3,2,1,1,3,2,1]"
|
||||
explanation: "The array ans is formed as follows: ans = [nums[0],nums[1],nums[2],nums[3],nums[0],nums[1],nums[2],nums[3]] = [1,3,2,1,1,3,2,1]"
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Imagine you have a deck of cards and you want to create a copy of it placed right after the original. The result is simply the same sequence repeated twice.
|
||||
|
||||
This problem is fundamentally about **array duplication and concatenation**. The key insight is that we need to produce an output array that contains the original array followed by itself. There's no complex logic involved — we're simply copying elements twice.
|
||||
|
||||
Think of it like this: if you have a row of numbered boxes, you create an identical row and place it at the end. The first half of your result mirrors the input, and so does the second half.
|
||||
|
||||
The simplicity of this problem makes it an excellent introduction to basic array manipulation and understanding index relationships.
|
||||
|
||||
approach: |
|
||||
We solve this using a **Direct Concatenation Approach**:
|
||||
|
||||
**Step 1: Create the result array**
|
||||
|
||||
- Initialise an empty array `ans` that will hold `2n` elements
|
||||
- We can either pre-allocate space or build it dynamically
|
||||
|
||||
|
||||
|
||||
**Step 2: Fill the first half**
|
||||
|
||||
- Copy all elements from `nums` into positions `0` to `n-1` of `ans`
|
||||
- This mirrors the original array exactly
|
||||
|
||||
|
||||
|
||||
**Step 3: Fill the second half**
|
||||
|
||||
- Copy all elements from `nums` into positions `n` to `2n-1` of `ans`
|
||||
- This creates the duplicate portion
|
||||
|
||||
|
||||
|
||||
**Step 4: Return the result**
|
||||
|
||||
- Return `ans` containing the concatenated array
|
||||
|
||||
|
||||
|
||||
In most languages, this can be simplified using built-in concatenation operators or methods. Python allows `nums + nums` or `nums * 2` for direct concatenation.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Off-by-One Errors in Manual Implementation
|
||||
description: |
|
||||
When manually filling the result array using indices, a common mistake is getting the index calculation wrong for the second half.
|
||||
|
||||
For the second half, element `nums[i]` should go to `ans[i + n]`, not `ans[i + n - 1]` or `ans[i + n + 1]`.
|
||||
|
||||
Example with `nums = [1, 2, 3]` (n=3):
|
||||
- `nums[0]` goes to `ans[0]` AND `ans[3]`
|
||||
- `nums[1]` goes to `ans[1]` AND `ans[4]`
|
||||
- `nums[2]` goes to `ans[2]` AND `ans[5]`
|
||||
wrong_approach: "Using incorrect index offset for second half"
|
||||
correct_approach: "Use ans[i + n] = nums[i] for the second half"
|
||||
|
||||
- title: Modifying the Original Array
|
||||
description: |
|
||||
Some approaches might try to extend the original array in-place. While this works, it modifies the input which may not be desired.
|
||||
|
||||
Creating a new array preserves the original input and is generally cleaner.
|
||||
wrong_approach: "Extending nums in-place"
|
||||
correct_approach: "Create a new result array"
|
||||
|
||||
- title: Overcomplicating the Solution
|
||||
description: |
|
||||
This is intentionally a simple problem. Don't overthink it by using complex data structures or algorithms.
|
||||
|
||||
A straightforward concatenation using language features is the expected solution. In Python, `nums + nums` or `nums * 2` is perfectly acceptable and idiomatic.
|
||||
|
||||
key_takeaways:
|
||||
- "**Array concatenation basics**: Understanding how to combine arrays is fundamental to many problems"
|
||||
- "**Index relationships**: The mapping `ans[i] = ans[i + n] = nums[i]` demonstrates how indices relate across duplicated data"
|
||||
- "**Language idioms**: Most languages have built-in ways to concatenate arrays — use them when appropriate"
|
||||
- "**Simplicity is valid**: Not every problem requires complex algorithms; recognising simple solutions is a skill"
|
||||
|
||||
time_complexity: "O(n). We iterate through the input array once (or twice with explicit copying), where `n` is the length of `nums`."
|
||||
space_complexity: "O(n). The output array `ans` has length `2n`, which is O(n) additional space. Note: if we count output as required space, it's O(2n) = O(n)."
|
||||
|
||||
solutions:
|
||||
- approach_name: Built-in Concatenation
|
||||
is_optimal: true
|
||||
code: |
|
||||
def get_concatenation(nums: list[int]) -> list[int]:
|
||||
# Python allows direct list concatenation with +
|
||||
# This creates a new list with nums followed by nums
|
||||
return nums + nums
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Creating the concatenated list requires copying all elements twice.
|
||||
|
||||
**Space Complexity:** O(n) — The result array has `2n` elements.
|
||||
|
||||
This is the most Pythonic solution. The `+` operator creates a new list containing all elements from both operands. Alternatively, `nums * 2` achieves the same result by repeating the list twice.
|
||||
|
||||
- approach_name: Manual Index Copying
|
||||
is_optimal: false
|
||||
code: |
|
||||
def get_concatenation(nums: list[int]) -> list[int]:
|
||||
n = len(nums)
|
||||
# Pre-allocate array of size 2n
|
||||
ans = [0] * (2 * n)
|
||||
|
||||
# Fill both halves in a single pass
|
||||
for i in range(n):
|
||||
# First half: direct copy
|
||||
ans[i] = nums[i]
|
||||
# Second half: offset by n
|
||||
ans[i + n] = nums[i]
|
||||
|
||||
return ans
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Single pass through the input array.
|
||||
|
||||
**Space Complexity:** O(n) — The result array has `2n` elements.
|
||||
|
||||
This approach explicitly demonstrates the index relationship described in the problem. While more verbose than using built-in concatenation, it clearly shows how `ans[i]` and `ans[i + n]` both receive `nums[i]`. This is useful for understanding the underlying mechanics and translates well to languages without convenient concatenation operators.
|
||||
|
||||
- approach_name: List Comprehension
|
||||
is_optimal: false
|
||||
code: |
|
||||
def get_concatenation(nums: list[int]) -> list[int]:
|
||||
n = len(nums)
|
||||
# Use modulo to wrap index back to start
|
||||
return [nums[i % n] for i in range(2 * n)]
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Iterates through `2n` indices.
|
||||
|
||||
**Space Complexity:** O(n) — The result list has `2n` elements.
|
||||
|
||||
This approach uses modulo arithmetic to cycle through the original array twice. When `i >= n`, the expression `i % n` wraps back to give indices `0, 1, 2, ...` again. While clever, it's less readable than direct concatenation and adds unnecessary computation with the modulo operation.
|
||||
@@ -0,0 +1,231 @@
|
||||
title: Construct Binary Tree from Preorder and Inorder Traversal
|
||||
slug: construct-binary-tree-from-preorder-and-inorder-traversal
|
||||
difficulty: medium
|
||||
leetcode_id: 105
|
||||
leetcode_url: https://leetcode.com/problems/construct-binary-tree-from-preorder-and-inorder-traversal/
|
||||
categories:
|
||||
- trees
|
||||
- arrays
|
||||
- hash-tables
|
||||
patterns:
|
||||
- dfs
|
||||
- tree-traversal
|
||||
|
||||
description: |
|
||||
Given two integer arrays `preorder` and `inorder` where `preorder` is the preorder traversal of a binary tree and `inorder` is the inorder traversal of the same tree, construct and return *the binary tree*.
|
||||
|
||||
constraints: |
|
||||
- `1 <= preorder.length <= 3000`
|
||||
- `inorder.length == preorder.length`
|
||||
- `-3000 <= preorder[i], inorder[i] <= 3000`
|
||||
- `preorder` and `inorder` consist of **unique** values
|
||||
- Each value of `inorder` also appears in `preorder`
|
||||
- `preorder` is **guaranteed** to be the preorder traversal of the tree
|
||||
- `inorder` is **guaranteed** to be the inorder traversal of the tree
|
||||
|
||||
examples:
|
||||
- input: "preorder = [3,9,20,15,7], inorder = [9,3,15,20,7]"
|
||||
output: "[3,9,20,null,null,15,7]"
|
||||
explanation: "The root is 3, with left subtree rooted at 9 and right subtree rooted at 20, which has children 15 and 7."
|
||||
- input: "preorder = [-1], inorder = [-1]"
|
||||
output: "[-1]"
|
||||
explanation: "A single node tree with value -1."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Think of this problem as a **detective puzzle** where you have two different witnesses describing the same family tree from different perspectives.
|
||||
|
||||
The key insight lies in understanding what each traversal tells us:
|
||||
|
||||
- **Preorder** visits: `root -> left subtree -> right subtree`. This means the **first element is always the root**.
|
||||
- **Inorder** visits: `left subtree -> root -> right subtree`. This means once we know the root, **everything to its left belongs to the left subtree**, and everything to its right belongs to the right subtree.
|
||||
|
||||
Imagine the inorder array as a "splitter". Once we identify the root from preorder, we can find it in inorder to **partition** the elements into left and right subtrees. Then we recursively apply the same logic to each subtree.
|
||||
|
||||
For example, with `preorder = [3,9,20,15,7]` and `inorder = [9,3,15,20,7]`:
|
||||
1. Root is `3` (first in preorder)
|
||||
2. In inorder, `3` is at index 1, so left subtree has `[9]` and right subtree has `[15,20,7]`
|
||||
3. Recursively build left subtree (root `9`) and right subtree (root `20`)
|
||||
|
||||
This divide-and-conquer approach naturally constructs the tree from top to bottom.
|
||||
|
||||
approach: |
|
||||
We solve this using **Divide and Conquer with Hash Map Optimisation**:
|
||||
|
||||
**Step 1: Build a lookup map**
|
||||
|
||||
- Create a hash map from `value -> index` for the inorder array
|
||||
- This allows O(1) lookup of any value's position in inorder
|
||||
- Without this, we'd need O(n) search each time, making the solution O(n^2)
|
||||
|
||||
|
||||
|
||||
**Step 2: Define recursive build function**
|
||||
|
||||
- Parameters track the current range in both preorder and inorder arrays
|
||||
- `pre_start`, `pre_end`: bounds in preorder array
|
||||
- `in_start`, `in_end`: bounds in inorder array
|
||||
- Base case: if `pre_start > pre_end`, return `None` (empty subtree)
|
||||
|
||||
|
||||
|
||||
**Step 3: Identify root and partition**
|
||||
|
||||
- The root value is `preorder[pre_start]` (first element in current preorder range)
|
||||
- Find root's index in inorder using the hash map: `root_idx`
|
||||
- Calculate left subtree size: `left_size = root_idx - in_start`
|
||||
|
||||
|
||||
|
||||
**Step 4: Recursively build subtrees**
|
||||
|
||||
- **Left subtree**:
|
||||
- Preorder range: `[pre_start + 1, pre_start + left_size]`
|
||||
- Inorder range: `[in_start, root_idx - 1]`
|
||||
- **Right subtree**:
|
||||
- Preorder range: `[pre_start + left_size + 1, pre_end]`
|
||||
- Inorder range: `[root_idx + 1, in_end]`
|
||||
|
||||
|
||||
|
||||
**Step 5: Connect and return**
|
||||
|
||||
- Create the root node with the identified value
|
||||
- Attach left and right children from recursive calls
|
||||
- Return the root node
|
||||
|
||||
common_pitfalls:
|
||||
- title: Linear Search in Inorder
|
||||
description: |
|
||||
A common mistake is searching for the root in inorder array with a loop each time:
|
||||
|
||||
```python
|
||||
root_idx = inorder.index(root_val) # O(n) each call!
|
||||
```
|
||||
|
||||
With recursion depth of O(n) and O(n) search per level, this gives **O(n^2)** time complexity. For `n = 3000`, this is 9 million operations and may TLE.
|
||||
|
||||
Solution: Pre-build a hash map for O(1) lookups.
|
||||
wrong_approach: "Linear search for root index each recursion"
|
||||
correct_approach: "Hash map for O(1) index lookup"
|
||||
|
||||
- title: Incorrect Range Calculations
|
||||
description: |
|
||||
The trickiest part is correctly computing the preorder ranges for subtrees. The left subtree in preorder starts right after the root (`pre_start + 1`) and spans `left_size` elements.
|
||||
|
||||
Common error: Using inorder indices for preorder ranges, or off-by-one errors in calculating where the right subtree begins.
|
||||
|
||||
Draw out a small example and trace the indices carefully.
|
||||
wrong_approach: "Confusing inorder indices with preorder indices"
|
||||
correct_approach: "Calculate left_size from inorder, apply to preorder ranges"
|
||||
|
||||
- title: Forgetting Base Case
|
||||
description: |
|
||||
Without a proper base case, recursion continues infinitely. When `pre_start > pre_end` (or equivalently `in_start > in_end`), the current subtree is empty and should return `None`.
|
||||
|
||||
Some implementations use array slicing instead of indices, which handles this naturally but uses O(n) extra space per call.
|
||||
|
||||
key_takeaways:
|
||||
- "**Traversal properties**: Preorder's first element is always root; inorder partitions left/right subtrees"
|
||||
- "**Divide and conquer**: Break the problem into smaller subproblems (left and right subtrees) and combine results"
|
||||
- "**Hash map optimisation**: Pre-compute lookups to reduce O(n^2) to O(n)"
|
||||
- "**Foundation for similar problems**: Same technique applies to constructing from postorder + inorder, or serialisation/deserialisation"
|
||||
|
||||
time_complexity: "O(n). Each node is visited exactly once, and hash map lookups are O(1)."
|
||||
space_complexity: "O(n). The hash map stores n elements, and recursion stack can be O(n) in worst case (skewed tree)."
|
||||
|
||||
solutions:
|
||||
- approach_name: Divide and Conquer with Hash Map
|
||||
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 build_tree(preorder: list[int], inorder: list[int]) -> TreeNode | None:
|
||||
# Build hash map for O(1) index lookup in inorder
|
||||
inorder_map = {val: idx for idx, val in enumerate(inorder)}
|
||||
|
||||
def build(pre_start: int, pre_end: int, in_start: int, in_end: int) -> TreeNode | None:
|
||||
# Base case: empty subtree
|
||||
if pre_start > pre_end:
|
||||
return None
|
||||
|
||||
# Root is first element in current preorder range
|
||||
root_val = preorder[pre_start]
|
||||
root = TreeNode(root_val)
|
||||
|
||||
# Find root position in inorder (O(1) lookup)
|
||||
root_idx = inorder_map[root_val]
|
||||
|
||||
# Calculate size of left subtree
|
||||
left_size = root_idx - in_start
|
||||
|
||||
# Recursively build left subtree
|
||||
# Preorder: elements after root, spanning left_size
|
||||
# Inorder: elements before root
|
||||
root.left = build(
|
||||
pre_start + 1, pre_start + left_size,
|
||||
in_start, root_idx - 1
|
||||
)
|
||||
|
||||
# Recursively build right subtree
|
||||
# Preorder: elements after left subtree
|
||||
# Inorder: elements after root
|
||||
root.right = build(
|
||||
pre_start + left_size + 1, pre_end,
|
||||
root_idx + 1, in_end
|
||||
)
|
||||
|
||||
return root
|
||||
|
||||
return build(0, len(preorder) - 1, 0, len(inorder) - 1)
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Each node processed once with O(1) hash map lookup.
|
||||
|
||||
**Space Complexity:** O(n) — Hash map stores n entries; recursion stack up to O(n) for skewed trees.
|
||||
|
||||
The hash map transforms what would be O(n) searches into O(1) lookups, making this the optimal approach. The recursive structure naturally mirrors the tree's hierarchy.
|
||||
|
||||
- approach_name: Divide and Conquer with Array Slicing
|
||||
is_optimal: false
|
||||
code: |
|
||||
class TreeNode:
|
||||
def __init__(self, val=0, left=None, right=None):
|
||||
self.val = val
|
||||
self.left = left
|
||||
self.right = right
|
||||
|
||||
def build_tree(preorder: list[int], inorder: list[int]) -> TreeNode | None:
|
||||
# Base case: empty arrays
|
||||
if not preorder or not inorder:
|
||||
return None
|
||||
|
||||
# Root is first element of preorder
|
||||
root_val = preorder[0]
|
||||
root = TreeNode(root_val)
|
||||
|
||||
# Find root in inorder (O(n) search)
|
||||
root_idx = inorder.index(root_val)
|
||||
|
||||
# Elements before root_idx in inorder are left subtree
|
||||
# Elements after root_idx in inorder are right subtree
|
||||
# Slice preorder accordingly (skip first element which is root)
|
||||
root.left = build_tree(
|
||||
preorder[1:root_idx + 1],
|
||||
inorder[:root_idx]
|
||||
)
|
||||
root.right = build_tree(
|
||||
preorder[root_idx + 1:],
|
||||
inorder[root_idx + 1:]
|
||||
)
|
||||
|
||||
return root
|
||||
explanation: |
|
||||
**Time Complexity:** O(n^2) — Linear search in inorder at each level, with n levels worst case.
|
||||
|
||||
**Space Complexity:** O(n^2) — Array slicing creates new arrays at each recursion level.
|
||||
|
||||
This approach is more intuitive and readable, making it good for understanding the algorithm. However, the linear search and array slicing make it less efficient. For small inputs it works fine, but may TLE on larger test cases.
|
||||
251
backend/data/questions/construct-quad-tree.yaml
Normal file
251
backend/data/questions/construct-quad-tree.yaml
Normal file
@@ -0,0 +1,251 @@
|
||||
title: Construct Quad Tree
|
||||
slug: construct-quad-tree
|
||||
difficulty: medium
|
||||
leetcode_id: 427
|
||||
leetcode_url: https://leetcode.com/problems/construct-quad-tree/
|
||||
categories:
|
||||
- arrays
|
||||
- trees
|
||||
- recursion
|
||||
patterns:
|
||||
- matrix-traversal
|
||||
- dfs
|
||||
|
||||
description: |
|
||||
Given a `n * n` matrix `grid` of `0`s and `1`s only. We want to represent `grid` with a Quad-Tree.
|
||||
|
||||
Return *the root of the Quad-Tree representing* `grid`.
|
||||
|
||||
A Quad-Tree is a tree data structure in which each internal node has exactly four children. Besides, each node has two attributes:
|
||||
|
||||
- `val`: `True` if the node represents a grid of `1`s or `False` if the node represents a grid of `0`s. Notice that you can assign the `val` to `True` or `False` when `isLeaf` is `False`, and both are accepted in the answer.
|
||||
- `isLeaf`: `True` if the node is a leaf node on the tree or `False` if the node has four children.
|
||||
|
||||
We can construct a Quad-Tree from a two-dimensional area using the following steps:
|
||||
|
||||
1. If the current grid has the same value (i.e., all `1`s or all `0`s), set `isLeaf` to `True` and set `val` to the value of the grid and set the four children to `None` and stop.
|
||||
2. If the current grid has different values, set `isLeaf` to `False` and set `val` to any value and divide the current grid into four sub-grids.
|
||||
3. Recurse for each of the children with the proper sub-grid.
|
||||
|
||||
constraints: |
|
||||
- `n == grid.length == grid[i].length`
|
||||
- `n == 2^x` where `0 <= x <= 6`
|
||||
|
||||
examples:
|
||||
- input: "grid = [[0,1],[1,0]]"
|
||||
output: "[[0,1],[1,0],[1,1],[1,1],[1,0]]"
|
||||
explanation: "The grid has mixed values, so we create an internal node with four leaf children. Each quadrant contains a single cell, so each becomes a leaf node with its respective value."
|
||||
- input: "grid = [[1,1,1,1,0,0,0,0],[1,1,1,1,0,0,0,0],[1,1,1,1,1,1,1,1],[1,1,1,1,1,1,1,1],[1,1,1,1,0,0,0,0],[1,1,1,1,0,0,0,0],[1,1,1,1,0,0,0,0],[1,1,1,1,0,0,0,0]]"
|
||||
output: "[[0,1],[1,1],[0,1],[1,1],[1,0],null,null,null,null,[1,0],[1,0],[1,1],[1,1]]"
|
||||
explanation: "The grid is divided into four quadrants. The top-left, bottom-left, and bottom-right quadrants each have uniform values and become leaf nodes. The top-right quadrant has mixed values, so it is further subdivided into four leaf nodes."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Imagine you have a satellite image that you want to compress. If a region of the image is all one colour, you can represent it with a single value instead of storing every pixel. But if a region has mixed colours, you need to look at it more closely by dividing it into smaller sections.
|
||||
|
||||
This is exactly how a **Quad-Tree** works. Think of it like a recursive "zoom in" strategy:
|
||||
|
||||
- Look at the entire grid. Is it all `0`s or all `1`s?
|
||||
- If yes, you're done — represent it with a single leaf node
|
||||
- If no, divide it into four equal quadrants and ask the same question for each
|
||||
|
||||
The key insight is that this naturally follows a **divide-and-conquer** approach. At each level, we either stop (uniform region → leaf) or recursively process four smaller subproblems. Since the grid size is always a power of 2, we can always divide evenly until we reach individual cells.
|
||||
|
||||
The elegance of this approach is that homogeneous regions get compressed into single nodes regardless of their size, while only heterogeneous regions require the full tree structure.
|
||||
|
||||
approach: |
|
||||
We solve this using a **Divide and Conquer** approach with recursion:
|
||||
|
||||
**Step 1: Define the recursive function**
|
||||
|
||||
- Create a helper function that takes the grid and the boundaries of the current region (row start, column start, and size)
|
||||
- This function will return a `Node` representing that region
|
||||
|
||||
|
||||
|
||||
**Step 2: Check if the region is uniform**
|
||||
|
||||
- Scan all cells in the current region
|
||||
- If all cells have the same value, create a leaf node with that value
|
||||
- Return immediately — no need to recurse further
|
||||
|
||||
|
||||
|
||||
**Step 3: Divide into four quadrants**
|
||||
|
||||
- If the region has mixed values, calculate the midpoint (`size // 2`)
|
||||
- Recursively build nodes for each quadrant:
|
||||
- **Top-left**: starts at `(row, col)`
|
||||
- **Top-right**: starts at `(row, col + half)`
|
||||
- **Bottom-left**: starts at `(row + half, col)`
|
||||
- **Bottom-right**: starts at `(row + half, col + half)`
|
||||
|
||||
|
||||
|
||||
**Step 4: Combine into an internal node**
|
||||
|
||||
- Create a non-leaf node with `isLeaf = False`
|
||||
- Set its four children to the nodes returned from the recursive calls
|
||||
- The `val` attribute can be any value for non-leaf nodes (we use `True` by convention)
|
||||
|
||||
|
||||
|
||||
**Step 5: Start the recursion**
|
||||
|
||||
- Call the helper with the full grid boundaries: `(0, 0, n)`
|
||||
- Return the resulting root node
|
||||
|
||||
common_pitfalls:
|
||||
- title: Checking Uniformity Inefficiently
|
||||
description: |
|
||||
A naive approach might check uniformity by iterating through all cells every time, even when recursing into smaller regions. This leads to O(n^2 log n) time complexity.
|
||||
|
||||
The standard approach with O(n^2) checks at each level is acceptable since we only do this once per node, and the total work across all levels is bounded.
|
||||
|
||||
However, for a more optimized solution, you could use prefix sums to check uniformity in O(1) time per region.
|
||||
wrong_approach: "Re-scanning the entire grid at every recursion level"
|
||||
correct_approach: "Only scan the current region being processed"
|
||||
|
||||
- title: Incorrect Quadrant Boundaries
|
||||
description: |
|
||||
When dividing the grid, it's easy to get the boundaries wrong. The four quadrants for a region starting at `(row, col)` with size `s` are:
|
||||
|
||||
- Top-left: `(row, col)` with size `s/2`
|
||||
- Top-right: `(row, col + s/2)` with size `s/2`
|
||||
- Bottom-left: `(row + s/2, col)` with size `s/2`
|
||||
- Bottom-right: `(row + s/2, col + s/2)` with size `s/2`
|
||||
|
||||
Off-by-one errors here will cause incorrect tree construction or index out of bounds.
|
||||
wrong_approach: "Using inconsistent indexing for quadrant boundaries"
|
||||
correct_approach: "Use half = size // 2 and apply consistently to both row and column offsets"
|
||||
|
||||
- title: Forgetting the Base Case
|
||||
description: |
|
||||
The recursion naturally stops when a region is uniform, but you should also handle the case when size is 1. A single cell is always uniform, so it becomes a leaf node.
|
||||
|
||||
This is implicitly handled by the uniformity check, but it's good to be aware of it as the logical base case.
|
||||
|
||||
key_takeaways:
|
||||
- "**Divide and Conquer**: When a problem can be broken into identical subproblems on smaller regions, recursion is the natural approach"
|
||||
- "**Quad-Trees for 2D data**: This structure is widely used in image compression, spatial indexing, and collision detection in games"
|
||||
- "**Power of 2 constraint**: The guarantee that `n = 2^x` ensures we can always divide evenly, simplifying the logic"
|
||||
- "**Lazy evaluation principle**: Only subdivide when necessary — uniform regions don't need further processing"
|
||||
|
||||
time_complexity: "O(n^2 log n) in the worst case. Each cell may be visited O(log n) times across different recursion levels. However, for grids with large uniform regions, many cells are only visited once."
|
||||
space_complexity: "O(log n) for the recursion stack depth. The output tree size is O(n^2) in the worst case but is not counted as auxiliary space."
|
||||
|
||||
solutions:
|
||||
- approach_name: Divide and Conquer
|
||||
is_optimal: true
|
||||
code: |
|
||||
class Node:
|
||||
def __init__(self, val: bool, isLeaf: bool,
|
||||
topLeft: 'Node' = None, topRight: 'Node' = None,
|
||||
bottomLeft: 'Node' = None, bottomRight: 'Node' = None):
|
||||
self.val = val
|
||||
self.isLeaf = isLeaf
|
||||
self.topLeft = topLeft
|
||||
self.topRight = topRight
|
||||
self.bottomLeft = bottomLeft
|
||||
self.bottomRight = bottomRight
|
||||
|
||||
|
||||
def construct(grid: list[list[int]]) -> Node:
|
||||
def build(row: int, col: int, size: int) -> Node:
|
||||
# Check if all cells in this region have the same value
|
||||
first_val = grid[row][col]
|
||||
is_uniform = True
|
||||
|
||||
for r in range(row, row + size):
|
||||
for c in range(col, col + size):
|
||||
if grid[r][c] != first_val:
|
||||
is_uniform = False
|
||||
break
|
||||
if not is_uniform:
|
||||
break
|
||||
|
||||
# If uniform, create a leaf node
|
||||
if is_uniform:
|
||||
return Node(val=bool(first_val), isLeaf=True)
|
||||
|
||||
# Otherwise, divide into four quadrants
|
||||
half = size // 2
|
||||
return Node(
|
||||
val=True, # Value doesn't matter for non-leaf
|
||||
isLeaf=False,
|
||||
topLeft=build(row, col, half),
|
||||
topRight=build(row, col + half, half),
|
||||
bottomLeft=build(row + half, col, half),
|
||||
bottomRight=build(row + half, col + half, half)
|
||||
)
|
||||
|
||||
return build(0, 0, len(grid))
|
||||
explanation: |
|
||||
**Time Complexity:** O(n^2 log n) — Each level of recursion processes the entire grid, and there are O(log n) levels.
|
||||
|
||||
**Space Complexity:** O(log n) — Recursion stack depth equals the number of levels in the tree.
|
||||
|
||||
We recursively check each region for uniformity. If uniform, we create a leaf. Otherwise, we split into four quadrants and recurse. The tree structure naturally emerges from the recursion.
|
||||
|
||||
- approach_name: Divide and Conquer with Prefix Sum
|
||||
is_optimal: false
|
||||
code: |
|
||||
class Node:
|
||||
def __init__(self, val: bool, isLeaf: bool,
|
||||
topLeft: 'Node' = None, topRight: 'Node' = None,
|
||||
bottomLeft: 'Node' = None, bottomRight: 'Node' = None):
|
||||
self.val = val
|
||||
self.isLeaf = isLeaf
|
||||
self.topLeft = topLeft
|
||||
self.topRight = topRight
|
||||
self.bottomLeft = bottomLeft
|
||||
self.bottomRight = bottomRight
|
||||
|
||||
|
||||
def construct(grid: list[list[int]]) -> Node:
|
||||
n = len(grid)
|
||||
|
||||
# Build prefix sum for O(1) region sum queries
|
||||
prefix = [[0] * (n + 1) for _ in range(n + 1)]
|
||||
for r in range(n):
|
||||
for c in range(n):
|
||||
prefix[r + 1][c + 1] = (grid[r][c] +
|
||||
prefix[r][c + 1] +
|
||||
prefix[r + 1][c] -
|
||||
prefix[r][c])
|
||||
|
||||
def region_sum(row: int, col: int, size: int) -> int:
|
||||
# Sum of region from (row, col) to (row+size-1, col+size-1)
|
||||
return (prefix[row + size][col + size] -
|
||||
prefix[row][col + size] -
|
||||
prefix[row + size][col] +
|
||||
prefix[row][col])
|
||||
|
||||
def build(row: int, col: int, size: int) -> Node:
|
||||
total = region_sum(row, col, size)
|
||||
area = size * size
|
||||
|
||||
# Check uniformity: sum is 0 (all 0s) or sum equals area (all 1s)
|
||||
if total == 0:
|
||||
return Node(val=False, isLeaf=True)
|
||||
if total == area:
|
||||
return Node(val=True, isLeaf=True)
|
||||
|
||||
# Mixed region: divide into quadrants
|
||||
half = size // 2
|
||||
return Node(
|
||||
val=True,
|
||||
isLeaf=False,
|
||||
topLeft=build(row, col, half),
|
||||
topRight=build(row, col + half, half),
|
||||
bottomLeft=build(row + half, col, half),
|
||||
bottomRight=build(row + half, col + half, half)
|
||||
)
|
||||
|
||||
return build(0, 0, n)
|
||||
explanation: |
|
||||
**Time Complexity:** O(n^2) — Prefix sum construction is O(n^2), and each node creation is O(1) with O(n^2) nodes maximum.
|
||||
|
||||
**Space Complexity:** O(n^2) — For the prefix sum array.
|
||||
|
||||
This optimised version uses a 2D prefix sum to check region uniformity in O(1) time. If the sum of a region is 0, all cells are 0. If the sum equals the area, all cells are 1. Otherwise, the region has mixed values and must be subdivided. While this has better time complexity, the extra space makes it a trade-off.
|
||||
183
backend/data/questions/contains-duplicate-ii.yaml
Normal file
183
backend/data/questions/contains-duplicate-ii.yaml
Normal file
@@ -0,0 +1,183 @@
|
||||
title: Contains Duplicate II
|
||||
slug: contains-duplicate-ii
|
||||
difficulty: easy
|
||||
leetcode_id: 219
|
||||
leetcode_url: https://leetcode.com/problems/contains-duplicate-ii/
|
||||
categories:
|
||||
- arrays
|
||||
- hash-tables
|
||||
patterns:
|
||||
- sliding-window
|
||||
|
||||
description: |
|
||||
Given an integer array `nums` and an integer `k`, return `true` *if there are two **distinct indices*** `i` *and* `j` *in the array such that* `nums[i] == nums[j]` *and* `abs(i - j) <= k`.
|
||||
|
||||
constraints: |
|
||||
- `1 <= nums.length <= 10^5`
|
||||
- `-10^9 <= nums[i] <= 10^9`
|
||||
- `0 <= k <= 10^5`
|
||||
|
||||
examples:
|
||||
- input: "nums = [1,2,3,1], k = 3"
|
||||
output: "true"
|
||||
explanation: "The element 1 appears at index 0 and index 3. Since abs(0 - 3) = 3 <= k, we return true."
|
||||
- input: "nums = [1,0,1,1], k = 1"
|
||||
output: "true"
|
||||
explanation: "The element 1 appears at index 2 and index 3. Since abs(2 - 3) = 1 <= k, we return true."
|
||||
- input: "nums = [1,2,3,1,2,3], k = 2"
|
||||
output: "false"
|
||||
explanation: "While there are duplicates, no pair of duplicate values are within k = 2 indices of each other. The closest duplicate pair (1 at index 0 and 3) has distance 3 > k."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Imagine you're walking through a hallway with numbered rooms, and you need to find if any room number repeats within the last `k` rooms you've passed.
|
||||
|
||||
The core insight is that we don't need to remember *every* room we've ever seen — we only care about rooms within our **sliding window** of the last `k` positions. If we encounter a room number we've seen within this window, we've found our duplicate.
|
||||
|
||||
Think of it like this: as you move forward, you maintain a "memory" of the last `k` rooms. When you see a new room number, you check if it's already in your memory. If yes, you found a nearby duplicate. If not, add it to your memory and forget the oldest room (the one that's now more than `k` steps behind).
|
||||
|
||||
This naturally suggests using a **hash set** as our memory — it gives us O(1) lookups to check for duplicates and O(1) insertions/deletions to maintain our sliding window.
|
||||
|
||||
approach: |
|
||||
We solve this using a **Sliding Window with Hash Set** approach:
|
||||
|
||||
**Step 1: Initialise a hash set**
|
||||
|
||||
- Create an empty set `window` to store elements within our current window of size `k`
|
||||
- The set will contain at most `k` elements at any time
|
||||
|
||||
|
||||
|
||||
**Step 2: Iterate through the array**
|
||||
|
||||
- For each element at index `i`, check if it already exists in our `window` set
|
||||
- If yes, we found a duplicate within distance `k` — return `true`
|
||||
- If no, add the current element to the window
|
||||
|
||||
|
||||
|
||||
**Step 3: Maintain window size**
|
||||
|
||||
- If the window size exceeds `k`, remove the oldest element (the one at index `i - k`)
|
||||
- This ensures we only track elements within the valid distance
|
||||
|
||||
|
||||
|
||||
**Step 4: Return the result**
|
||||
|
||||
- If we complete the loop without finding duplicates, return `false`
|
||||
|
||||
|
||||
|
||||
This approach efficiently combines the sliding window pattern with a hash set for O(1) operations, giving us an optimal O(n) solution.
|
||||
|
||||
common_pitfalls:
|
||||
- title: The Brute Force Trap
|
||||
description: |
|
||||
A naive approach checks every pair of elements to see if they're equal and within distance `k`:
|
||||
- Outer loop `i` from `0` to `n-1`
|
||||
- Inner loop `j` from `i+1` to `min(i+k+1, n)`
|
||||
|
||||
While this limits the inner loop to `k` iterations, it's still **O(n × k)** in the worst case. When both `n` and `k` are at their maximum (`10^5`), this results in up to 10 billion operations — causing a **Time Limit Exceeded (TLE)** error.
|
||||
wrong_approach: "Nested loops checking pairs within distance k"
|
||||
correct_approach: "Sliding window with hash set for O(n) time"
|
||||
|
||||
- title: Using a Hash Map Instead of a Set
|
||||
description: |
|
||||
While a hash map (storing value → index) works, it's more complex than necessary. You'd need to update indices as you go and compare distances.
|
||||
|
||||
A hash set is simpler: by maintaining exactly the last `k` elements, we implicitly guarantee any match is within the valid distance. If it's in the set, it's within range.
|
||||
wrong_approach: "Hash map with index tracking and distance calculation"
|
||||
correct_approach: "Hash set with sliding window of size k"
|
||||
|
||||
- title: Off-by-One in Window Size
|
||||
description: |
|
||||
Be careful about when to remove elements from the window. The condition `abs(i - j) <= k` means indices can be up to `k` apart, so your window should contain `k` previous elements (not `k-1` or `k+1`).
|
||||
|
||||
Remove the element at index `i - k` only when `i >= k`, ensuring the window never exceeds `k` elements from the past.
|
||||
wrong_approach: "Removing when i > k or keeping k+1 elements"
|
||||
correct_approach: "Remove element at index i - k when i >= k"
|
||||
|
||||
key_takeaways:
|
||||
- "**Sliding window + hash set**: When you need to find duplicates within a range, combine a fixed-size window with a set for O(1) lookups"
|
||||
- "**Implicit distance guarantee**: By maintaining exactly `k` elements, any match is automatically within the valid distance — no need to track indices"
|
||||
- "**Set vs Map tradeoff**: Choose the simpler data structure when it suffices; a set is often cleaner than a map when you don't need the stored values"
|
||||
- "**Related problems**: This pattern extends to 'Contains Duplicate III' (within range *and* value difference) and other sliding window problems"
|
||||
|
||||
time_complexity: "O(n). We traverse the array once, with O(1) hash set operations (add, remove, lookup) at each step."
|
||||
space_complexity: "O(min(n, k)). The hash set stores at most `min(n, k)` elements at any time."
|
||||
|
||||
solutions:
|
||||
- approach_name: Sliding Window with Hash Set
|
||||
is_optimal: true
|
||||
code: |
|
||||
def contains_nearby_duplicate(nums: list[int], k: int) -> bool:
|
||||
# Set to track elements in our current window of size k
|
||||
window = set()
|
||||
|
||||
for i, num in enumerate(nums):
|
||||
# If we've seen this number in our window, we found a duplicate
|
||||
if num in window:
|
||||
return True
|
||||
|
||||
# Add current element to the window
|
||||
window.add(num)
|
||||
|
||||
# Maintain window size: remove element that's now too far behind
|
||||
if i >= k:
|
||||
window.remove(nums[i - k])
|
||||
|
||||
# No nearby duplicates found
|
||||
return False
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Single pass through the array with O(1) set operations.
|
||||
|
||||
**Space Complexity:** O(min(n, k)) — The set contains at most k elements.
|
||||
|
||||
We maintain a sliding window of the last k elements using a hash set. For each new element, we check if it's already in the window (O(1) lookup). If found, we have a duplicate within distance k. Otherwise, we add it and remove the oldest element to maintain the window size.
|
||||
|
||||
- approach_name: Hash Map with Index Tracking
|
||||
is_optimal: false
|
||||
code: |
|
||||
def contains_nearby_duplicate(nums: list[int], k: int) -> bool:
|
||||
# Map each value to its most recent index
|
||||
last_seen = {}
|
||||
|
||||
for i, num in enumerate(nums):
|
||||
# Check if we've seen this number before
|
||||
if num in last_seen:
|
||||
# Check if the previous occurrence is within distance k
|
||||
if i - last_seen[num] <= k:
|
||||
return True
|
||||
|
||||
# Update the most recent index for this number
|
||||
last_seen[num] = i
|
||||
|
||||
return False
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Single pass with O(1) hash map operations.
|
||||
|
||||
**Space Complexity:** O(n) — In the worst case, all elements are unique and stored in the map.
|
||||
|
||||
This approach stores the last seen index for each value. When we encounter a number we've seen before, we check if the distance is within k. While correct and efficient, it uses more space than the sliding window approach when k is small relative to n.
|
||||
|
||||
- approach_name: Brute Force
|
||||
is_optimal: false
|
||||
code: |
|
||||
def contains_nearby_duplicate(nums: list[int], k: int) -> bool:
|
||||
n = len(nums)
|
||||
|
||||
# Check each element against the next k elements
|
||||
for i in range(n):
|
||||
# Only check within the valid range
|
||||
for j in range(i + 1, min(i + k + 1, n)):
|
||||
if nums[i] == nums[j]:
|
||||
return True
|
||||
|
||||
return False
|
||||
explanation: |
|
||||
**Time Complexity:** O(n × k) — For each element, we check up to k subsequent elements.
|
||||
|
||||
**Space Complexity:** O(1) — No additional data structures used.
|
||||
|
||||
This straightforward approach checks every valid pair. While it passes small test cases, it will TLE on large inputs where both n and k approach 10^5. Included to illustrate why the hash-based approaches are necessary.
|
||||
210
backend/data/questions/contains-duplicate.yaml
Normal file
210
backend/data/questions/contains-duplicate.yaml
Normal file
@@ -0,0 +1,210 @@
|
||||
title: Contains Duplicate
|
||||
slug: contains-duplicate
|
||||
difficulty: easy
|
||||
leetcode_id: 217
|
||||
leetcode_url: https://leetcode.com/problems/contains-duplicate/
|
||||
categories:
|
||||
- arrays
|
||||
- hash-tables
|
||||
patterns:
|
||||
- heap
|
||||
|
||||
function_signature: "def contains_duplicate(nums: list[int]) -> bool:"
|
||||
|
||||
test_cases:
|
||||
visible:
|
||||
- input: { nums: [1, 2, 3, 1] }
|
||||
expected: true
|
||||
- input: { nums: [1, 2, 3, 4] }
|
||||
expected: false
|
||||
- input: { nums: [1, 1, 1, 3, 3, 4, 3, 2, 4, 2] }
|
||||
expected: true
|
||||
hidden:
|
||||
- input: { nums: [1] }
|
||||
expected: false
|
||||
- input: { nums: [1, 1] }
|
||||
expected: true
|
||||
- input: { nums: [-1, -1, -2, -3] }
|
||||
expected: true
|
||||
- input: { nums: [0, 0] }
|
||||
expected: true
|
||||
|
||||
description: |
|
||||
Given an integer array `nums`, return `true` if any value appears **at least twice** in the array, and return `false` if every element is distinct.
|
||||
|
||||
constraints: |
|
||||
- `1 <= nums.length <= 10^5`
|
||||
- `-10^9 <= nums[i] <= 10^9`
|
||||
|
||||
examples:
|
||||
- input: "nums = [1,2,3,1]"
|
||||
output: "true"
|
||||
explanation: "The element 1 occurs at the indices 0 and 3."
|
||||
- input: "nums = [1,2,3,4]"
|
||||
output: "false"
|
||||
explanation: "All elements are distinct."
|
||||
- input: "nums = [1,1,1,3,3,4,3,2,4,2]"
|
||||
output: "true"
|
||||
explanation: "Multiple elements appear more than once."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Imagine you're checking coats at a party and need to ensure no two guests have the same ticket number. As each guest arrives, you could compare their ticket to every previous ticket — but that gets tedious as the party grows. Instead, what if you kept a quick-reference list of all ticket numbers you've seen?
|
||||
|
||||
This is the core insight: **use a data structure that allows instant lookups** to check if you've seen a number before. A *hash set* provides exactly this capability — adding an element and checking membership both take O(1) average time.
|
||||
|
||||
Think of it like this: as you iterate through the array, you maintain a "memory" of all numbers encountered so far. For each new number, you ask: "Have I seen this before?" If yes, you've found a duplicate. If no, add it to your memory and continue.
|
||||
|
||||
The key constraint guiding our solution is the array size (up to 10^5 elements). This rules out O(n^2) approaches and points us toward O(n) or O(n log n) solutions.
|
||||
|
||||
approach: |
|
||||
We solve this using a **Hash Set Approach**:
|
||||
|
||||
**Step 1: Create an empty set**
|
||||
|
||||
- `seen`: An empty set to store numbers we've encountered
|
||||
- Sets provide O(1) average time for both insertion and membership testing
|
||||
|
||||
|
||||
|
||||
**Step 2: Iterate through the array**
|
||||
|
||||
- For each number in `nums`, check if it already exists in `seen`
|
||||
- If the number is in `seen`, we've found a duplicate — return `True` immediately
|
||||
- If the number is not in `seen`, add it to the set and continue
|
||||
|
||||
|
||||
|
||||
**Step 3: Return the result**
|
||||
|
||||
- If we complete the loop without finding any duplicates, return `False`
|
||||
- This means all elements were distinct
|
||||
|
||||
|
||||
|
||||
This approach works because hash sets give us constant-time lookups. We trade space (storing up to n elements) for time (avoiding nested comparisons).
|
||||
|
||||
common_pitfalls:
|
||||
- title: The Brute Force Trap
|
||||
description: |
|
||||
A natural first instinct is to compare every pair of elements:
|
||||
- Outer loop `i` from `0` to `n-1`
|
||||
- Inner loop `j` from `i+1` to `n-1`
|
||||
- Check if `nums[i] == nums[j]`
|
||||
|
||||
This results in **O(n^2) time complexity**. With `nums.length <= 10^5`, this means up to 5 billion comparisons — guaranteed **Time Limit Exceeded (TLE)**.
|
||||
|
||||
The hash set approach reduces this to O(n) by eliminating the inner loop entirely.
|
||||
wrong_approach: "Nested loops comparing all pairs"
|
||||
correct_approach: "Hash set for O(1) membership testing"
|
||||
|
||||
- title: Sorting Without Understanding the Trade-off
|
||||
description: |
|
||||
Sorting the array first (O(n log n)) then checking adjacent elements works, but it has two downsides:
|
||||
- Slower than the hash set approach for this specific problem
|
||||
- Modifies the original array (or requires O(n) extra space for a copy)
|
||||
|
||||
However, sorting can be preferable when memory is extremely constrained, as it uses O(1) extra space if done in-place.
|
||||
wrong_approach: "Always defaulting to sorting"
|
||||
correct_approach: "Choose hash set for O(n) time when space permits"
|
||||
|
||||
- title: Using a List Instead of a Set
|
||||
description: |
|
||||
In Python, checking `if x in list` is O(n), not O(1). Using a list instead of a set turns your "optimised" solution back into O(n^2).
|
||||
|
||||
```python
|
||||
# Wrong - O(n^2) total
|
||||
seen = []
|
||||
for num in nums:
|
||||
if num in seen: # O(n) lookup!
|
||||
return True
|
||||
seen.append(num)
|
||||
```
|
||||
|
||||
Always use a set (or dict) for membership testing.
|
||||
|
||||
key_takeaways:
|
||||
- "**Hash sets for membership testing**: When you need to check 'have I seen this before?', a set gives O(1) lookups"
|
||||
- "**Space-time trade-off**: Using O(n) extra space gives us O(n) time instead of O(n^2)"
|
||||
- "**Early exit optimisation**: Return immediately when a duplicate is found — no need to check the rest"
|
||||
- "**Foundation for harder problems**: This pattern appears in problems like Two Sum, finding pairs, and detecting cycles"
|
||||
|
||||
time_complexity: "O(n). We traverse the array once, with O(1) set operations at each step."
|
||||
space_complexity: "O(n). In the worst case (all unique elements), we store all n elements in the set."
|
||||
|
||||
solutions:
|
||||
- approach_name: Hash Set
|
||||
is_optimal: true
|
||||
code: |
|
||||
def contains_duplicate(nums: list[int]) -> bool:
|
||||
# Set to track numbers we've seen
|
||||
seen = set()
|
||||
|
||||
for num in nums:
|
||||
# Already seen this number? Duplicate found!
|
||||
if num in seen:
|
||||
return True
|
||||
# First time seeing this number, remember it
|
||||
seen.add(num)
|
||||
|
||||
# No duplicates found after checking all elements
|
||||
return False
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Single pass through the array with O(1) set operations.
|
||||
|
||||
**Space Complexity:** O(n) — Set stores up to n elements in the worst case.
|
||||
|
||||
We iterate once, checking each number against our set of seen values. The moment we find a number already in the set, we return `True`. If we finish without finding duplicates, we return `False`.
|
||||
|
||||
- approach_name: One-liner with Set Length
|
||||
is_optimal: true
|
||||
code: |
|
||||
def contains_duplicate(nums: list[int]) -> bool:
|
||||
# If set has fewer elements than list, duplicates exist
|
||||
return len(nums) != len(set(nums))
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Building a set from the list is O(n).
|
||||
|
||||
**Space Complexity:** O(n) — The set stores up to n elements.
|
||||
|
||||
This elegant one-liner exploits the fact that sets automatically remove duplicates. If the set has fewer elements than the original list, at least one duplicate existed. Note: this always processes all elements, unlike the early-exit version above.
|
||||
|
||||
- approach_name: Sorting
|
||||
is_optimal: false
|
||||
code: |
|
||||
def contains_duplicate(nums: list[int]) -> bool:
|
||||
# Sort the array so duplicates become adjacent
|
||||
nums.sort()
|
||||
|
||||
# Check adjacent pairs for duplicates
|
||||
for i in range(1, len(nums)):
|
||||
if nums[i] == nums[i - 1]:
|
||||
return True
|
||||
|
||||
return False
|
||||
explanation: |
|
||||
**Time Complexity:** O(n log n) — Dominated by the sorting step.
|
||||
|
||||
**Space Complexity:** O(1) — In-place sorting uses constant extra space (ignoring the recursion stack).
|
||||
|
||||
After sorting, any duplicates will be adjacent. We scan through checking consecutive pairs. This approach is useful when memory is extremely limited, but it modifies the original array.
|
||||
|
||||
- approach_name: Brute Force
|
||||
is_optimal: false
|
||||
code: |
|
||||
def contains_duplicate(nums: list[int]) -> bool:
|
||||
n = len(nums)
|
||||
|
||||
# Compare every pair of elements
|
||||
for i in range(n):
|
||||
for j in range(i + 1, n):
|
||||
if nums[i] == nums[j]:
|
||||
return True
|
||||
|
||||
return False
|
||||
explanation: |
|
||||
**Time Complexity:** O(n^2) — Nested loops comparing all pairs.
|
||||
|
||||
**Space Complexity:** O(1) — No extra data structures used.
|
||||
|
||||
This straightforward approach checks every possible pair. While correct, it's far too slow for large inputs (TLE on LeetCode). Included to illustrate why hash-based approaches are essential.
|
||||
202
backend/data/questions/continuous-subarray-sum.yaml
Normal file
202
backend/data/questions/continuous-subarray-sum.yaml
Normal file
@@ -0,0 +1,202 @@
|
||||
title: Continuous Subarray Sum
|
||||
slug: continuous-subarray-sum
|
||||
difficulty: medium
|
||||
leetcode_id: 523
|
||||
leetcode_url: https://leetcode.com/problems/continuous-subarray-sum/
|
||||
categories:
|
||||
- arrays
|
||||
- hash-tables
|
||||
- math
|
||||
patterns:
|
||||
- prefix-sum
|
||||
|
||||
description: |
|
||||
Given an integer array `nums` and an integer `k`, return `true` if `nums` has a **good subarray** or `false` otherwise.
|
||||
|
||||
A **good subarray** is a subarray where:
|
||||
|
||||
- its length is **at least two**, and
|
||||
- the sum of the elements of the subarray is a multiple of `k`.
|
||||
|
||||
**Note** that:
|
||||
|
||||
- A **subarray** is a contiguous part of the array.
|
||||
- An integer `x` is a multiple of `k` if there exists an integer `n` such that `x = n * k`. `0` is **always** a multiple of `k`.
|
||||
|
||||
constraints: |
|
||||
- `1 <= nums.length <= 10^5`
|
||||
- `0 <= nums[i] <= 10^9`
|
||||
- `0 <= sum(nums[i]) <= 2^31 - 1`
|
||||
- `1 <= k <= 2^31 - 1`
|
||||
|
||||
examples:
|
||||
- input: "nums = [23,2,4,6,7], k = 6"
|
||||
output: "true"
|
||||
explanation: "[2, 4] is a continuous subarray of size 2 whose elements sum up to 6."
|
||||
- input: "nums = [23,2,6,4,7], k = 6"
|
||||
output: "true"
|
||||
explanation: "[23, 2, 6, 4, 7] is a continuous subarray of size 5 whose elements sum up to 42. 42 is a multiple of 6 because 42 = 7 * 6."
|
||||
- input: "nums = [23,2,6,4,7], k = 13"
|
||||
output: "false"
|
||||
explanation: "No contiguous subarray of length at least 2 has a sum that is a multiple of 13."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
This problem seems to ask us to check every possible subarray sum, but that would be too slow. The key insight comes from **modular arithmetic** and **prefix sums**.
|
||||
|
||||
Imagine you're tracking a running total as you walk through the array. At each position, you calculate the prefix sum up to that point. Now, here's the crucial observation: if two prefix sums have the **same remainder when divided by `k`**, then the subarray between them has a sum that's a **multiple of `k`**.
|
||||
|
||||
Think of it like this: if `prefix[i] % k == prefix[j] % k` where `j > i`, then:
|
||||
- `prefix[j] - prefix[i]` gives the sum of elements from index `i+1` to `j`
|
||||
- Since both have the same remainder, their difference is divisible by `k`
|
||||
|
||||
For example, with `k = 6`: if `prefix[2] = 25` (remainder 1) and `prefix[5] = 49` (remainder 1), then the sum from index 3 to 5 is `49 - 25 = 24`, which is divisible by 6.
|
||||
|
||||
So instead of checking all subarray sums, we just need to find two positions with the **same prefix sum remainder** that are at least 2 indices apart.
|
||||
|
||||
approach: |
|
||||
We solve this using a **Prefix Sum with Hash Map** approach:
|
||||
|
||||
**Step 1: Initialise the hash map**
|
||||
|
||||
- Create a hash map `remainder_index` to store the first index where each remainder was seen
|
||||
- Set `remainder_index[0] = -1` to handle the case where the subarray starts from index 0
|
||||
- Initialise `prefix_sum = 0` to track the running total
|
||||
|
||||
|
||||
|
||||
**Step 2: Iterate through the array**
|
||||
|
||||
- For each element at index `i`, add it to `prefix_sum`
|
||||
- Calculate `remainder = prefix_sum % k`
|
||||
- If this remainder exists in our hash map:
|
||||
- Check if `i - remainder_index[remainder] >= 2` (subarray length at least 2)
|
||||
- If yes, return `true` — we found a valid subarray
|
||||
- If this remainder is not in the hash map:
|
||||
- Store it with the current index: `remainder_index[remainder] = i`
|
||||
- We only store the *first* occurrence to maximise subarray length
|
||||
|
||||
|
||||
|
||||
**Step 3: Return the result**
|
||||
|
||||
- If we complete the loop without finding a valid subarray, return `false`
|
||||
|
||||
|
||||
|
||||
The key optimisation is that we only store the **first occurrence** of each remainder. This ensures that when we find a matching remainder later, the subarray between them is as long as possible, giving us the best chance of meeting the length requirement.
|
||||
|
||||
common_pitfalls:
|
||||
- title: The Brute Force Trap
|
||||
description: |
|
||||
A naive approach would check every possible subarray:
|
||||
- Outer loop `i` from `0` to `n-1`
|
||||
- Inner loop `j` from `i+1` to `n-1`
|
||||
- Calculate sum from `i` to `j` and check if divisible by `k`
|
||||
|
||||
This results in **O(n^2) time complexity** (or O(n^3) if summing naively). With `n = 10^5`, this means up to 10 billion operations — guaranteed **Time Limit Exceeded**.
|
||||
wrong_approach: "Nested loops checking all subarray sums"
|
||||
correct_approach: "Prefix sum with hash map for O(n) time"
|
||||
|
||||
- title: Forgetting the Base Case
|
||||
description: |
|
||||
The hash map must be initialised with `{0: -1}`, not empty. This handles subarrays that start from index 0.
|
||||
|
||||
For example, with `nums = [6, 1]` and `k = 6`:
|
||||
- After index 0: `prefix_sum = 6`, `remainder = 0`
|
||||
- Without `{0: -1}`, we wouldn't find a match
|
||||
- With `{0: -1}`, we check `0 - (-1) = 1`, which fails the length check
|
||||
- After index 1: `prefix_sum = 7`, `remainder = 1`
|
||||
- But with `nums = [6, 6]`, after index 1: `remainder = 0`, and `1 - (-1) = 2` passes!
|
||||
wrong_approach: "Starting with an empty hash map"
|
||||
correct_approach: "Initialise with {0: -1} for subarrays starting at index 0"
|
||||
|
||||
- title: Updating Instead of Keeping First Index
|
||||
description: |
|
||||
When we see a remainder we've seen before, we should NOT update the hash map. We want the **earliest** index for each remainder to maximise the subarray length.
|
||||
|
||||
If we keep updating, we might miss valid subarrays:
|
||||
- `remainder = 3` first seen at index 1
|
||||
- `remainder = 3` seen again at index 2 (if we update, we lose index 1)
|
||||
- `remainder = 3` seen at index 4: checking against index 2 gives length 2, but index 1 would give length 3
|
||||
wrong_approach: "Always updating the hash map with current index"
|
||||
correct_approach: "Only store the first occurrence of each remainder"
|
||||
|
||||
- title: Off-by-One in Length Check
|
||||
description: |
|
||||
The problem requires subarray length **at least 2**, not just "more than 1". The check should be `i - remainder_index[remainder] >= 2`.
|
||||
|
||||
With indices `i` and `j` where `j < i`, the subarray from `j+1` to `i` has length `i - j`. So we need `i - j >= 2`.
|
||||
wrong_approach: "Checking `> 1` or `> 2` incorrectly"
|
||||
correct_approach: "Check `i - stored_index >= 2`"
|
||||
|
||||
key_takeaways:
|
||||
- "**Prefix sum + hash map**: A powerful combination for subarray sum problems. Store prefix sums (or their remainders) to find subarrays with specific properties in O(n) time."
|
||||
- "**Modular arithmetic insight**: If `prefix[i] % k == prefix[j] % k`, then the sum between them is divisible by `k`. This transforms a sum problem into a remainder-matching problem."
|
||||
- "**Base case matters**: Initialising with `{0: -1}` handles subarrays starting from index 0. Always consider edge cases involving the array start."
|
||||
- "**Related problems**: This pattern applies to [Subarray Sum Equals K](/questions/subarray-sum-equals-k), Subarray Sums Divisible by K, and other prefix sum problems."
|
||||
|
||||
time_complexity: "O(n). We traverse the array once, and hash map operations (insert, lookup) are O(1) on average."
|
||||
space_complexity: "O(min(n, k)). The hash map stores at most `k` distinct remainders (0 to k-1), or `n` entries if `n < k`."
|
||||
|
||||
solutions:
|
||||
- approach_name: Prefix Sum with Hash Map
|
||||
is_optimal: true
|
||||
code: |
|
||||
def check_subarray_sum(nums: list[int], k: int) -> bool:
|
||||
# Map: remainder -> first index where this remainder was seen
|
||||
# {0: -1} handles subarrays starting from index 0
|
||||
remainder_index = {0: -1}
|
||||
prefix_sum = 0
|
||||
|
||||
for i, num in enumerate(nums):
|
||||
# Update running prefix sum
|
||||
prefix_sum += num
|
||||
|
||||
# Get remainder when divided by k
|
||||
remainder = prefix_sum % k
|
||||
|
||||
# Have we seen this remainder before?
|
||||
if remainder in remainder_index:
|
||||
# Check if subarray length is at least 2
|
||||
if i - remainder_index[remainder] >= 2:
|
||||
return True
|
||||
# Don't update - keep the earliest index
|
||||
else:
|
||||
# First time seeing this remainder - store the index
|
||||
remainder_index[remainder] = i
|
||||
|
||||
return False
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Single pass through the array with O(1) hash map operations.
|
||||
|
||||
**Space Complexity:** O(min(n, k)) — At most `k` distinct remainders can exist.
|
||||
|
||||
We use the mathematical property that two prefix sums with the same remainder mod `k` define a subarray whose sum is divisible by `k`. By storing only the first occurrence of each remainder, we maximise our chances of finding a subarray of length at least 2.
|
||||
|
||||
- approach_name: Brute Force
|
||||
is_optimal: false
|
||||
code: |
|
||||
def check_subarray_sum(nums: list[int], k: int) -> bool:
|
||||
n = len(nums)
|
||||
|
||||
# Try every starting position
|
||||
for i in range(n - 1):
|
||||
# Accumulate sum for subarrays starting at i
|
||||
subarray_sum = nums[i]
|
||||
|
||||
# Try every ending position (at least 2 elements)
|
||||
for j in range(i + 1, n):
|
||||
subarray_sum += nums[j]
|
||||
|
||||
# Check if sum is multiple of k
|
||||
if subarray_sum % k == 0:
|
||||
return True
|
||||
|
||||
return False
|
||||
explanation: |
|
||||
**Time Complexity:** O(n^2) — Nested loops checking all subarrays.
|
||||
|
||||
**Space Complexity:** O(1) — Only tracking the running sum.
|
||||
|
||||
This approach checks every possible subarray of length at least 2. While correct, it exceeds time limits for large inputs (n = 10^5). Included to illustrate why the prefix sum approach is necessary.
|
||||
224
backend/data/questions/convert-bst-to-greater-tree.yaml
Normal file
224
backend/data/questions/convert-bst-to-greater-tree.yaml
Normal file
@@ -0,0 +1,224 @@
|
||||
title: Convert BST to Greater Tree
|
||||
slug: convert-bst-to-greater-tree
|
||||
difficulty: medium
|
||||
leetcode_id: 538
|
||||
leetcode_url: https://leetcode.com/problems/convert-bst-to-greater-tree/
|
||||
categories:
|
||||
- trees
|
||||
patterns:
|
||||
- dfs
|
||||
- tree-traversal
|
||||
|
||||
description: |
|
||||
Given the `root` of a Binary Search Tree (BST), convert it to a Greater Tree such that every key of the original BST is changed to the original key plus the sum of all keys greater than the original key in BST.
|
||||
|
||||
As a reminder, a *binary search tree* is a tree that satisfies these constraints:
|
||||
|
||||
- The left subtree of a node contains only nodes with keys **less than** the node's key.
|
||||
- The right subtree of a node contains only nodes with keys **greater than** the node's key.
|
||||
- Both the left and right subtrees must also be binary search trees.
|
||||
|
||||
constraints: |
|
||||
- `0 <= number of nodes <= 10^4`
|
||||
- `-10^4 <= Node.val <= 10^4`
|
||||
- All the values in the tree are **unique**
|
||||
- `root` is guaranteed to be a valid binary search tree
|
||||
|
||||
examples:
|
||||
- input: "root = [4,1,6,0,2,5,7,null,null,null,3,null,null,null,8]"
|
||||
output: "[30,36,21,36,35,26,15,null,null,null,33,null,null,null,8]"
|
||||
explanation: "Each node's new value equals its original value plus the sum of all nodes with greater values. For example, node 4 becomes 4 + 5 + 6 + 7 + 8 = 30."
|
||||
- input: "root = [0,null,1]"
|
||||
output: "[1,null,1]"
|
||||
explanation: "Node 1 stays 1 (no greater values). Node 0 becomes 0 + 1 = 1."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Imagine you have a sorted list of numbers and you want to replace each number with the sum of itself and all numbers greater than it. You would naturally start from the **largest number** and work backwards, keeping a running total.
|
||||
|
||||
The key insight is that a BST has a built-in sorted order: an **in-order traversal** (left → node → right) visits nodes in ascending order. But we need descending order to accumulate sums from largest to smallest.
|
||||
|
||||
The solution is beautifully simple: use a **reverse in-order traversal** (right → node → left). This visits nodes from largest to smallest. As we traverse, we maintain a running sum of all values seen so far. Each node's new value becomes this running sum (which includes its original value plus all greater values).
|
||||
|
||||
Think of it like this: you're walking through the tree in reverse sorted order, carrying a "cumulative total" that grows with each node you visit. When you reach a node, you add its value to your total, then update the node with this new total.
|
||||
|
||||
approach: |
|
||||
We solve this using **Reverse In-Order Traversal** with a running sum:
|
||||
|
||||
**Step 1: Understand the traversal order**
|
||||
|
||||
- Standard in-order (left → node → right) gives ascending order
|
||||
- Reverse in-order (right → node → left) gives **descending order**
|
||||
- We need descending order to accumulate sums correctly
|
||||
|
||||
|
||||
|
||||
**Step 2: Initialise a running sum variable**
|
||||
|
||||
- `running_sum`: Set to `0` initially
|
||||
- This tracks the sum of all nodes visited so far (all values greater than current)
|
||||
|
||||
|
||||
|
||||
**Step 3: Perform reverse in-order traversal**
|
||||
|
||||
- Recursively visit the **right subtree** first (larger values)
|
||||
- Process the **current node**: add its value to `running_sum`, then update the node's value to `running_sum`
|
||||
- Recursively visit the **left subtree** (smaller values)
|
||||
|
||||
|
||||
|
||||
**Step 4: Return the modified root**
|
||||
|
||||
- The tree is modified in-place during traversal
|
||||
- Return the original root reference
|
||||
|
||||
common_pitfalls:
|
||||
- title: Using Standard In-Order Traversal
|
||||
description: |
|
||||
A common mistake is using left → node → right traversal. This visits nodes in ascending order, but we need descending order to know the sum of greater values before processing each node.
|
||||
|
||||
With ascending order, when you visit a node, you don't yet know the sum of all greater values because you haven't visited them yet!
|
||||
wrong_approach: "Standard in-order traversal (left → node → right)"
|
||||
correct_approach: "Reverse in-order traversal (right → node → left)"
|
||||
|
||||
- title: Two-Pass Solution
|
||||
description: |
|
||||
Some might think they need two passes: first to calculate the total sum, then another to update each node. While this works, it's unnecessarily complex.
|
||||
|
||||
The reverse in-order traversal elegantly solves this in a single pass because we naturally visit larger values first.
|
||||
wrong_approach: "First pass to sum all values, second pass to update nodes"
|
||||
correct_approach: "Single reverse in-order pass with running sum"
|
||||
|
||||
- title: Forgetting to Update Running Sum Before Node Value
|
||||
description: |
|
||||
The order of operations matters. You must add the current node's value to the running sum **before** updating the node's value, otherwise you lose the original value.
|
||||
|
||||
```python
|
||||
# Wrong order:
|
||||
node.val = running_sum # Lost original value!
|
||||
running_sum += node.val # Now adding the wrong value
|
||||
|
||||
# Correct order:
|
||||
running_sum += node.val # Add original value first
|
||||
node.val = running_sum # Then update node
|
||||
```
|
||||
wrong_approach: "Update node value before adding to running sum"
|
||||
correct_approach: "Add to running sum first, then update node value"
|
||||
|
||||
key_takeaways:
|
||||
- "**Reverse in-order traversal** visits BST nodes in descending order — essential when you need to process from largest to smallest"
|
||||
- "**Running accumulator pattern**: maintain a running total during traversal when nodes depend on previously visited values"
|
||||
- "**BST property exploitation**: the sorted nature of BSTs enables elegant single-pass solutions"
|
||||
- "This problem is identical to LeetCode 1038 (Binary Search Tree to Greater Sum Tree)"
|
||||
|
||||
time_complexity: "O(n). Each node is visited exactly once during the traversal."
|
||||
space_complexity: "O(h) where h is the height of the tree. This is the recursion stack space — O(log n) for a balanced tree, O(n) for a skewed tree."
|
||||
|
||||
solutions:
|
||||
- approach_name: Reverse In-Order Traversal (Recursive)
|
||||
is_optimal: true
|
||||
code: |
|
||||
class TreeNode:
|
||||
def __init__(self, val=0, left=None, right=None):
|
||||
self.val = val
|
||||
self.left = left
|
||||
self.right = right
|
||||
|
||||
def convert_bst(root: TreeNode | None) -> TreeNode | None:
|
||||
# Running sum of all values greater than current node
|
||||
running_sum = 0
|
||||
|
||||
def reverse_inorder(node: TreeNode | None) -> None:
|
||||
nonlocal running_sum
|
||||
if not node:
|
||||
return
|
||||
|
||||
# Visit right subtree first (larger values)
|
||||
reverse_inorder(node.right)
|
||||
|
||||
# Process current node: add to sum, then update node
|
||||
running_sum += node.val
|
||||
node.val = running_sum
|
||||
|
||||
# Visit left subtree (smaller values)
|
||||
reverse_inorder(node.left)
|
||||
|
||||
reverse_inorder(root)
|
||||
return root
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Each node visited once.
|
||||
|
||||
**Space Complexity:** O(h) — Recursion stack depth equals tree height.
|
||||
|
||||
The recursive approach directly implements reverse in-order traversal. We use `nonlocal` to maintain the running sum across recursive calls. The tree is modified in-place.
|
||||
|
||||
- approach_name: Reverse In-Order Traversal (Iterative)
|
||||
is_optimal: true
|
||||
code: |
|
||||
def convert_bst(root: TreeNode | None) -> TreeNode | None:
|
||||
running_sum = 0
|
||||
stack = []
|
||||
current = root
|
||||
|
||||
# Iterative reverse in-order: right -> node -> left
|
||||
while stack or current:
|
||||
# Go as far right as possible
|
||||
while current:
|
||||
stack.append(current)
|
||||
current = current.right
|
||||
|
||||
# Process the rightmost unprocessed node
|
||||
current = stack.pop()
|
||||
running_sum += current.val
|
||||
current.val = running_sum
|
||||
|
||||
# Move to left subtree
|
||||
current = current.left
|
||||
|
||||
return root
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Each node visited once.
|
||||
|
||||
**Space Complexity:** O(h) — Stack stores at most h nodes.
|
||||
|
||||
The iterative version uses an explicit stack to simulate recursion. We traverse right as far as possible, then process nodes and move left. This avoids recursion overhead and stack overflow for very deep trees.
|
||||
|
||||
- approach_name: Morris Traversal
|
||||
is_optimal: true
|
||||
code: |
|
||||
def convert_bst(root: TreeNode | None) -> TreeNode | None:
|
||||
running_sum = 0
|
||||
current = root
|
||||
|
||||
while current:
|
||||
if not current.right:
|
||||
# No right child: process node, move left
|
||||
running_sum += current.val
|
||||
current.val = running_sum
|
||||
current = current.left
|
||||
else:
|
||||
# Find in-order predecessor in right subtree
|
||||
# (leftmost node of right subtree)
|
||||
predecessor = current.right
|
||||
while predecessor.left and predecessor.left != current:
|
||||
predecessor = predecessor.left
|
||||
|
||||
if not predecessor.left:
|
||||
# Create temporary link back to current
|
||||
predecessor.left = current
|
||||
current = current.right
|
||||
else:
|
||||
# Remove temporary link, process current node
|
||||
predecessor.left = None
|
||||
running_sum += current.val
|
||||
current.val = running_sum
|
||||
current = current.left
|
||||
|
||||
return root
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Each edge traversed at most twice.
|
||||
|
||||
**Space Complexity:** O(1) — No additional space beyond pointers.
|
||||
|
||||
Morris traversal achieves O(1) space by temporarily modifying tree pointers to create "threads" back to ancestors. This is the most space-efficient solution but modifies and restores the tree structure during traversal.
|
||||
@@ -0,0 +1,213 @@
|
||||
title: Convert Sorted Array to Binary Search Tree
|
||||
slug: convert-sorted-array-to-binary-search-tree
|
||||
difficulty: easy
|
||||
leetcode_id: 108
|
||||
leetcode_url: https://leetcode.com/problems/convert-sorted-array-to-binary-search-tree/
|
||||
categories:
|
||||
- arrays
|
||||
- trees
|
||||
- recursion
|
||||
patterns:
|
||||
- binary-search
|
||||
- dfs
|
||||
|
||||
description: |
|
||||
Given an integer array `nums` where the elements are sorted in **ascending order**, convert *it to a* ***height-balanced*** *binary search tree*.
|
||||
|
||||
A **height-balanced** binary tree is a binary tree in which the depth of the two subtrees of every node never differs by more than one.
|
||||
|
||||
constraints: |
|
||||
- `1 <= nums.length <= 10^4`
|
||||
- `-10^4 <= nums[i] <= 10^4`
|
||||
- `nums` is sorted in a **strictly increasing** order
|
||||
|
||||
examples:
|
||||
- input: "nums = [-10,-3,0,5,9]"
|
||||
output: "[0,-3,9,-10,null,5]"
|
||||
explanation: "[0,-10,5,null,-3,null,9] is also accepted. Both represent valid height-balanced BSTs."
|
||||
- input: "nums = [1,3]"
|
||||
output: "[3,1]"
|
||||
explanation: "[1,null,3] and [3,1] are both height-balanced BSTs."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Think of a sorted array as a flattened BST — specifically, the **inorder traversal** of a BST produces a sorted sequence. Our task is to reverse this process: reconstruct a balanced BST from its sorted elements.
|
||||
|
||||
The key insight comes from how BSTs work: the **middle element** of a sorted array naturally becomes the **root** of a balanced tree. Why? Because half the elements are smaller (they go in the left subtree) and half are larger (they go in the right subtree). This equal distribution is exactly what makes a tree height-balanced.
|
||||
|
||||
Imagine you're organising books on a balanced shelf. If you pick the middle book as your centre point, you'll have roughly the same number of books on each side. Now recursively apply this same logic to each side — pick the middle of the left half for the left shelf's centre, and the middle of the right half for the right shelf's centre.
|
||||
|
||||
This "divide and conquer" approach naturally produces a balanced structure because at every level of recursion, we're splitting our remaining elements as evenly as possible.
|
||||
|
||||
approach: |
|
||||
We solve this using a **Recursive Divide and Conquer** approach:
|
||||
|
||||
**Step 1: Define the recursive function**
|
||||
|
||||
- Create a helper function that takes the left and right boundaries of the current subarray
|
||||
- The function will return the root of the subtree built from elements in `nums[left..right]`
|
||||
|
||||
|
||||
|
||||
**Step 2: Base case**
|
||||
|
||||
- If `left > right`, we've exhausted this subarray — return `None` (empty subtree)
|
||||
|
||||
|
||||
|
||||
**Step 3: Find the middle element**
|
||||
|
||||
- Calculate `mid = (left + right) // 2` (or `left + (right - left) // 2` to avoid overflow in other languages)
|
||||
- The element at `nums[mid]` becomes the root of this subtree
|
||||
|
||||
|
||||
|
||||
**Step 4: Recursively build subtrees**
|
||||
|
||||
- Left subtree: recursively process `nums[left..mid-1]`
|
||||
- Right subtree: recursively process `nums[mid+1..right]`
|
||||
- Attach these as the left and right children of the current root
|
||||
|
||||
|
||||
|
||||
**Step 5: Return the root**
|
||||
|
||||
- Return the constructed node, which connects upward to its parent (or is the final tree root)
|
||||
|
||||
|
||||
|
||||
This approach guarantees a height-balanced tree because we always choose the middle element, ensuring each subtree has at most one more element than its sibling.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Off-by-One Errors in Boundaries
|
||||
description: |
|
||||
When setting up recursive boundaries, it's easy to make mistakes:
|
||||
- Left subtree should use `[left, mid - 1]`, not `[left, mid]` (which would include the root again)
|
||||
- Right subtree should use `[mid + 1, right]`, not `[mid, right]`
|
||||
|
||||
Including the middle element in a subtree creates duplicates and infinite recursion.
|
||||
wrong_approach: "Using [left, mid] for left subtree"
|
||||
correct_approach: "Using [left, mid - 1] for left subtree"
|
||||
|
||||
- title: Forgetting the Base Case
|
||||
description: |
|
||||
Without a proper base case (`left > right`), the recursion never terminates. This causes a stack overflow.
|
||||
|
||||
The base case handles empty subarrays — when there are no elements left to process in a branch, we return `None` to represent an empty child.
|
||||
wrong_approach: "No base case or incorrect condition"
|
||||
correct_approach: "Return None when left > right"
|
||||
|
||||
- title: Creating an Unbalanced Tree
|
||||
description: |
|
||||
If you don't choose the middle element as the root, you'll create an unbalanced tree.
|
||||
|
||||
For example, always choosing the first element creates a right-skewed tree (like a linked list), which defeats the purpose of a BST for efficient operations.
|
||||
|
||||
With `nums = [1, 2, 3, 4, 5]`, choosing `1` as root puts all other elements in the right subtree, giving height 4 instead of 2.
|
||||
wrong_approach: "Using first or last element as root"
|
||||
correct_approach: "Always use the middle element as root"
|
||||
|
||||
key_takeaways:
|
||||
- "**Divide and conquer**: Split problems into smaller subproblems by choosing a pivot (middle element) and recursing on each half"
|
||||
- "**BST property from sorted input**: The middle of a sorted array is the natural root for a balanced BST"
|
||||
- "**Foundation for tree construction**: This technique extends to problems like converting sorted linked lists to BSTs, or building balanced trees from other traversals"
|
||||
- "**Logarithmic height guarantee**: By always splitting evenly, the tree height is `O(log n)`, enabling efficient search, insert, and delete operations"
|
||||
|
||||
time_complexity: "O(n). We visit each element exactly once to create its corresponding tree node."
|
||||
space_complexity: "O(log n). The recursion stack depth equals the tree height, which is `O(log n)` for a balanced tree. The output tree uses O(n) space, but that's required by the problem."
|
||||
|
||||
solutions:
|
||||
- approach_name: Recursive Divide and Conquer
|
||||
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 sorted_array_to_bst(nums: list[int]) -> TreeNode | None:
|
||||
def build_tree(left: int, right: int) -> TreeNode | None:
|
||||
# Base case: no elements in this range
|
||||
if left > right:
|
||||
return None
|
||||
|
||||
# Choose middle element as root for balance
|
||||
mid = (left + right) // 2
|
||||
|
||||
# Create node with middle element
|
||||
node = TreeNode(nums[mid])
|
||||
|
||||
# Recursively build left subtree from left half
|
||||
node.left = build_tree(left, mid - 1)
|
||||
|
||||
# Recursively build right subtree from right half
|
||||
node.right = build_tree(mid + 1, right)
|
||||
|
||||
return node
|
||||
|
||||
return build_tree(0, len(nums) - 1)
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Each element is visited exactly once.
|
||||
|
||||
**Space Complexity:** O(log n) — Recursion stack depth for a balanced tree.
|
||||
|
||||
We use the classic divide-and-conquer pattern: select the middle element as root, then recursively construct left and right subtrees from the remaining elements. This guarantees the tree is height-balanced.
|
||||
|
||||
- approach_name: Iterative with Stack
|
||||
is_optimal: false
|
||||
code: |
|
||||
class TreeNode:
|
||||
def __init__(self, val=0, left=None, right=None):
|
||||
self.val = val
|
||||
self.left = left
|
||||
self.right = right
|
||||
|
||||
def sorted_array_to_bst(nums: list[int]) -> TreeNode | None:
|
||||
if not nums:
|
||||
return None
|
||||
|
||||
# Stack holds tuples: (node, left_bound, right_bound, is_left_child, parent)
|
||||
# We'll process nodes and attach them to parents
|
||||
|
||||
n = len(nums)
|
||||
mid = n // 2
|
||||
root = TreeNode(nums[mid])
|
||||
|
||||
# Stack entries: (left, right, parent_node, is_left)
|
||||
stack = []
|
||||
|
||||
# Add left and right ranges to process
|
||||
if mid - 1 >= 0:
|
||||
stack.append((0, mid - 1, root, True))
|
||||
if mid + 1 < n:
|
||||
stack.append((mid + 1, n - 1, root, False))
|
||||
|
||||
while stack:
|
||||
left, right, parent, is_left = stack.pop()
|
||||
|
||||
if left > right:
|
||||
continue
|
||||
|
||||
mid = (left + right) // 2
|
||||
node = TreeNode(nums[mid])
|
||||
|
||||
# Attach to parent
|
||||
if is_left:
|
||||
parent.left = node
|
||||
else:
|
||||
parent.right = node
|
||||
|
||||
# Add children ranges to stack
|
||||
if left <= mid - 1:
|
||||
stack.append((left, mid - 1, node, True))
|
||||
if mid + 1 <= right:
|
||||
stack.append((mid + 1, right, node, False))
|
||||
|
||||
return root
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Each element is processed once.
|
||||
|
||||
**Space Complexity:** O(log n) — Stack depth mirrors tree height.
|
||||
|
||||
This iterative approach simulates the recursion using an explicit stack. While it achieves the same result, the recursive solution is more intuitive and readable for tree problems. The iterative version is useful when recursion depth is a concern.
|
||||
210
backend/data/questions/copy-list-with-random-pointer.yaml
Normal file
210
backend/data/questions/copy-list-with-random-pointer.yaml
Normal file
@@ -0,0 +1,210 @@
|
||||
title: Copy List with Random Pointer
|
||||
slug: copy-list-with-random-pointer
|
||||
difficulty: medium
|
||||
leetcode_id: 138
|
||||
leetcode_url: https://leetcode.com/problems/copy-list-with-random-pointer/
|
||||
categories:
|
||||
- linked-lists
|
||||
- hash-tables
|
||||
patterns:
|
||||
- linkedlist-reversal
|
||||
|
||||
description: |
|
||||
A linked list of length `n` is given such that each node contains an additional random pointer, which could point to any node in the list, or `null`.
|
||||
|
||||
Construct a **deep copy** of the list. The deep copy should consist of exactly `n` **brand new** nodes, where each new node has its value set to the value of its corresponding original node. Both the `next` and `random` pointer of the new nodes should point to new nodes in the copied list such that the pointers in the original list and copied list represent the same list state. **None of the pointers in the new list should point to nodes in the original list**.
|
||||
|
||||
For example, if there are two nodes `X` and `Y` in the original list, where `X.random --> Y`, then for the corresponding two nodes `x` and `y` in the copied list, `x.random --> y`.
|
||||
|
||||
Return *the head of the copied linked list*.
|
||||
|
||||
The linked list is represented in the input/output as a list of `n` nodes. Each node is represented as a pair of `[val, random_index]` where:
|
||||
|
||||
- `val`: an integer representing `Node.val`
|
||||
- `random_index`: the index of the node (range from `0` to `n-1`) that the `random` pointer points to, or `null` if it does not point to any node.
|
||||
|
||||
Your code will **only** be given the `head` of the original linked list.
|
||||
|
||||
constraints: |
|
||||
- `0 <= n <= 1000`
|
||||
- `-10^4 <= Node.val <= 10^4`
|
||||
- `Node.random` is `null` or is pointing to some node in the linked list.
|
||||
|
||||
examples:
|
||||
- input: "head = [[7,null],[13,0],[11,4],[10,2],[1,0]]"
|
||||
output: "[[7,null],[13,0],[11,4],[10,2],[1,0]]"
|
||||
explanation: "A list with 5 nodes. Node 0 has value 7 and random points to null. Node 1 has value 13 and random points to node 0. Node 2 has value 11 and random points to node 4. And so on. The output is a deep copy with the same structure."
|
||||
- input: "head = [[1,1],[2,1]]"
|
||||
output: "[[1,1],[2,1]]"
|
||||
explanation: "Two nodes where both random pointers point to node 1 (the second node with value 2)."
|
||||
- input: "head = [[3,null],[3,0],[3,null]]"
|
||||
output: "[[3,null],[3,0],[3,null]]"
|
||||
explanation: "Three nodes all with value 3. The middle node's random pointer points to the first node."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Imagine you're tasked with duplicating a spider web where each thread (the `next` pointer) connects to the next junction, but there are also random silk threads (`random` pointers) that can connect any junction to any other junction — or nowhere at all.
|
||||
|
||||
The challenge is that when you're building your copy, you need to connect the `random` pointer of a copied node to another *copied* node. But how do you find the copied version of the node that the original `random` points to?
|
||||
|
||||
Think of it like this: if you're copying a contact list where each person has a "best friend" field pointing to another person in the same list, you can't fill in the "best friend" until you've created entries for *everyone*. The core insight is that you need a way to **map original nodes to their copies**.
|
||||
|
||||
There are two elegant approaches:
|
||||
1. **Hash Map**: Use a dictionary to store the mapping from each original node to its copy. This makes lookups instant but uses O(n) extra space.
|
||||
2. **Interweaving**: Cleverly weave copied nodes directly into the original list (A → A' → B → B' → ...), so each copy sits right next to its original. The original's `random.next` gives you the copy's random target. Then unweave to separate the lists.
|
||||
|
||||
approach: |
|
||||
We'll describe the **Hash Map Approach** as the primary solution due to its clarity:
|
||||
|
||||
**Step 1: Handle the edge case**
|
||||
|
||||
- If `head` is `null`, return `null` immediately
|
||||
|
||||
|
||||
|
||||
**Step 2: First pass — create all copied nodes**
|
||||
|
||||
- Traverse the original list
|
||||
- For each original node, create a new node with the same value
|
||||
- Store the mapping `original_node → copied_node` in a hash map
|
||||
- This ensures we have all copies created before wiring up pointers
|
||||
|
||||
|
||||
|
||||
**Step 3: Second pass — wire up `next` and `random` pointers**
|
||||
|
||||
- Traverse the original list again
|
||||
- For each original node:
|
||||
- Set `copy.next = hash_map[original.next]` (or `null` if `original.next` is `null`)
|
||||
- Set `copy.random = hash_map[original.random]` (or `null` if `original.random` is `null`)
|
||||
- The hash map allows O(1) lookup of any copied node
|
||||
|
||||
|
||||
|
||||
**Step 4: Return the copied head**
|
||||
|
||||
- Return `hash_map[head]` — the copy of the original head node
|
||||
|
||||
common_pitfalls:
|
||||
- title: Copying Random Pointers to Original Nodes
|
||||
description: |
|
||||
A common mistake is to set `copy.random = original.random`. This makes the copy's random pointer point to a node in the *original* list, not the copied list.
|
||||
|
||||
The problem explicitly states: "None of the pointers in the new list should point to nodes in the original list."
|
||||
|
||||
You must translate every pointer to point to the corresponding *copy*.
|
||||
wrong_approach: "copy.random = original.random"
|
||||
correct_approach: "copy.random = hash_map[original.random]"
|
||||
|
||||
- title: Single Pass Without Pre-Creating Nodes
|
||||
description: |
|
||||
You might try to copy nodes and wire pointers in a single pass. But consider: when processing node A, its `random` might point to node Z which you haven't created yet.
|
||||
|
||||
Either use two passes (create all nodes first, then wire pointers), or create nodes on-demand and check the hash map before creating duplicates.
|
||||
wrong_approach: "Single pass assuming nodes exist"
|
||||
correct_approach: "Two-pass or create-on-demand with hash map"
|
||||
|
||||
- title: Not Handling Null Pointers
|
||||
description: |
|
||||
Both `next` and `random` can be `null`. When looking up in the hash map, ensure you handle the case where the key is `null`.
|
||||
|
||||
In Python, `hash_map.get(None)` returns `None`, which is correct. In other languages, you may need explicit null checks.
|
||||
wrong_approach: "Unconditionally accessing hash_map[node]"
|
||||
correct_approach: "Check if node is null before hash map lookup"
|
||||
|
||||
key_takeaways:
|
||||
- "**Hash map for node mapping**: When cloning graph-like structures, a hash map from original to copy nodes enables O(1) pointer translation"
|
||||
- "**Two-pass pattern**: Create all nodes first, then wire connections — this avoids forward reference problems"
|
||||
- "**Space-time tradeoff**: The interweaving approach achieves O(1) space but is trickier to implement; hash map is clearer at O(n) space"
|
||||
- "**Deep copy fundamentals**: This problem teaches the core concept of deep copying interconnected structures — applicable to graphs, trees with parent pointers, and more"
|
||||
|
||||
time_complexity: "O(n). We traverse the list twice — once to create copies, once to wire pointers. Each node is visited a constant number of times."
|
||||
space_complexity: "O(n). The hash map stores a mapping for each of the `n` nodes. The output list itself also uses O(n) space, but that's required by the problem."
|
||||
|
||||
solutions:
|
||||
- approach_name: Hash Map
|
||||
is_optimal: true
|
||||
code: |
|
||||
class Node:
|
||||
def __init__(self, x: int, next: 'Node' = None, random: 'Node' = None):
|
||||
self.val = x
|
||||
self.next = next
|
||||
self.random = random
|
||||
|
||||
def copyRandomList(head: 'Node') -> 'Node':
|
||||
if not head:
|
||||
return None
|
||||
|
||||
# Map from original node to its copy
|
||||
old_to_new = {}
|
||||
|
||||
# First pass: create all copied nodes
|
||||
current = head
|
||||
while current:
|
||||
old_to_new[current] = Node(current.val)
|
||||
current = current.next
|
||||
|
||||
# Second pass: wire up next and random pointers
|
||||
current = head
|
||||
while current:
|
||||
copy = old_to_new[current]
|
||||
# Wire next pointer (use .get() to handle None gracefully)
|
||||
copy.next = old_to_new.get(current.next)
|
||||
# Wire random pointer
|
||||
copy.random = old_to_new.get(current.random)
|
||||
current = current.next
|
||||
|
||||
# Return the copy of the head
|
||||
return old_to_new[head]
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Two linear passes through the list.
|
||||
|
||||
**Space Complexity:** O(n) — Hash map stores n mappings.
|
||||
|
||||
The hash map approach is intuitive: first create all copies, then use the map to translate every pointer from the original domain to the copy domain. The `.get()` method handles `None` keys gracefully by returning `None`.
|
||||
|
||||
- approach_name: Interweaving Nodes
|
||||
is_optimal: false
|
||||
code: |
|
||||
def copyRandomList(head: 'Node') -> 'Node':
|
||||
if not head:
|
||||
return None
|
||||
|
||||
# Step 1: Interweave copied nodes into the original list
|
||||
# Original: A -> B -> C
|
||||
# After: A -> A' -> B -> B' -> C -> C'
|
||||
current = head
|
||||
while current:
|
||||
copy = Node(current.val)
|
||||
copy.next = current.next
|
||||
current.next = copy
|
||||
current = copy.next
|
||||
|
||||
# Step 2: Wire up random pointers for copied nodes
|
||||
current = head
|
||||
while current:
|
||||
copy = current.next
|
||||
if current.random:
|
||||
# The copy of current.random is current.random.next
|
||||
copy.random = current.random.next
|
||||
current = copy.next
|
||||
|
||||
# Step 3: Unweave the lists to separate original and copy
|
||||
current = head
|
||||
copy_head = head.next
|
||||
while current:
|
||||
copy = current.next
|
||||
# Restore original list
|
||||
current.next = copy.next
|
||||
# Wire copy list
|
||||
if copy.next:
|
||||
copy.next = copy.next.next
|
||||
current = current.next
|
||||
|
||||
return copy_head
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Three linear passes through the list.
|
||||
|
||||
**Space Complexity:** O(1) — No hash map; copies are woven into the original list temporarily.
|
||||
|
||||
This clever approach avoids extra space by placing each copy immediately after its original. The trick is that `original.random.next` gives us the copy of the random target. After wiring random pointers, we unweave to restore the original list and extract the copy. While space-efficient, it's more complex and modifies the original list temporarily.
|
||||
198
backend/data/questions/count-good-nodes-in-binary-tree.yaml
Normal file
198
backend/data/questions/count-good-nodes-in-binary-tree.yaml
Normal file
@@ -0,0 +1,198 @@
|
||||
title: Count Good Nodes in Binary Tree
|
||||
slug: count-good-nodes-in-binary-tree
|
||||
difficulty: medium
|
||||
leetcode_id: 1448
|
||||
leetcode_url: https://leetcode.com/problems/count-good-nodes-in-binary-tree/
|
||||
categories:
|
||||
- trees
|
||||
patterns:
|
||||
- dfs
|
||||
- tree-traversal
|
||||
|
||||
description: |
|
||||
Given a binary tree `root`, a node *X* in the tree is named **good** if in the path from root to *X* there are no nodes with a value *greater than* X.
|
||||
|
||||
Return the number of **good** nodes in the binary tree.
|
||||
|
||||
constraints: |
|
||||
- The number of nodes in the binary tree is in the range `[1, 10^5]`
|
||||
- Each node's value is between `[-10^4, 10^4]`
|
||||
|
||||
examples:
|
||||
- input: "root = [3,1,4,3,null,1,5]"
|
||||
output: "4"
|
||||
explanation: "Root node (3) is always good. Node 4 -> path (3,4) has max 4. Node 5 -> path (3,4,5) has max 5. Node 3 -> path (3,1,3) has max 3. These 4 nodes are good."
|
||||
- input: "root = [3,3,null,4,2]"
|
||||
output: "3"
|
||||
explanation: "Node 2 -> path (3,3,2) is not good because 3 is greater than 2."
|
||||
- input: "root = [1]"
|
||||
output: "1"
|
||||
explanation: "The root is always considered good."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Imagine you're hiking down a mountain with multiple branching paths. At each point along a path, you record the highest altitude you've reached so far. A node is "good" if its altitude is at least as high as the highest point you've seen on the path leading to it.
|
||||
|
||||
The key insight is that **you need to track the maximum value seen along the path from the root to the current node**. As you traverse the tree, you carry this "path maximum" with you. When you arrive at a node, you simply compare: is this node's value greater than or equal to the maximum seen so far?
|
||||
|
||||
Think of it like this: the root is always good (there's no ancestor to compare against). As you move down, each node either meets or exceeds the current path maximum (making it good) or falls short. The path maximum only increases as you descend — it never decreases because we're tracking the *maximum* value seen.
|
||||
|
||||
This naturally suggests a **depth-first search (DFS)** where we pass the current path maximum down to each child. The traversal order doesn't matter (preorder, inorder, postorder all work) because we're counting nodes, not processing them in any specific order.
|
||||
|
||||
approach: |
|
||||
We solve this using a **DFS with Path Maximum Tracking**:
|
||||
|
||||
**Step 1: Define the recursive function**
|
||||
|
||||
- Create a helper function `dfs(node, max_so_far)` that takes the current node and the maximum value seen on the path from root to this node
|
||||
- The function returns the count of good nodes in the subtree rooted at `node`
|
||||
|
||||
|
||||
|
||||
**Step 2: Handle the base case**
|
||||
|
||||
- If `node` is `None`, return `0` — there are no good nodes in an empty subtree
|
||||
|
||||
|
||||
|
||||
**Step 3: Check if current node is good**
|
||||
|
||||
- Compare `node.val` with `max_so_far`
|
||||
- If `node.val >= max_so_far`, this node is good — add `1` to our count
|
||||
- Update `max_so_far` to be the maximum of itself and `node.val` for child traversals
|
||||
|
||||
|
||||
|
||||
**Step 4: Recursively count good nodes in subtrees**
|
||||
|
||||
- Call `dfs(node.left, max_so_far)` to count good nodes in the left subtree
|
||||
- Call `dfs(node.right, max_so_far)` to count good nodes in the right subtree
|
||||
- Add both counts to the current node's count (0 or 1)
|
||||
|
||||
|
||||
|
||||
**Step 5: Start the traversal**
|
||||
|
||||
- Call `dfs(root, root.val)` — the root is always good since there's no ancestor with a greater value
|
||||
- Alternatively, start with `dfs(root, float('-inf'))` so the root naturally qualifies as good
|
||||
|
||||
common_pitfalls:
|
||||
- title: Modifying Path Maximum Incorrectly
|
||||
description: |
|
||||
A common mistake is updating `max_so_far` before checking if the current node is good.
|
||||
|
||||
For example:
|
||||
```python
|
||||
max_so_far = max(max_so_far, node.val) # Wrong order!
|
||||
if node.val >= max_so_far: # Always true now
|
||||
```
|
||||
|
||||
You should check if the node is good **first**, then update the maximum for child calls.
|
||||
wrong_approach: "Update max before checking if node is good"
|
||||
correct_approach: "Check if good first, then update max for children"
|
||||
|
||||
- title: Not Passing Updated Maximum to Children
|
||||
description: |
|
||||
When a node has a higher value than the current path maximum, you must pass this new maximum to its children. Forgetting to update means children might incorrectly be marked as good.
|
||||
|
||||
For instance, with path `[3, 5, 4]`, if you don't update the max at node 5, node 4 would compare against 3 instead of 5 and incorrectly be marked good.
|
||||
wrong_approach: "Pass the same max_so_far to all children regardless of current node"
|
||||
correct_approach: "Pass max(max_so_far, node.val) to children"
|
||||
|
||||
- title: Forgetting the Root is Always Good
|
||||
description: |
|
||||
The root node has no ancestors, so by definition there are no nodes with values greater than it on its path. The root is always good.
|
||||
|
||||
Initialize with `max_so_far = float('-inf')` or `max_so_far = root.val` to ensure the root counts as good. Don't start with an arbitrary value like `0` which could incorrectly exclude negative root values.
|
||||
wrong_approach: "Starting with max_so_far = 0"
|
||||
correct_approach: "Start with float('-inf') or root.val"
|
||||
|
||||
key_takeaways:
|
||||
- "**Path-based problems need state passing**: When a condition depends on the path from root to node, pass relevant state (like max/min/sum) down through recursion"
|
||||
- "**DFS naturally handles paths**: Unlike BFS which explores level-by-level, DFS follows paths from root to leaf, making it ideal for path-based conditions"
|
||||
- "**Order of operations matters**: Check conditions before updating state to avoid logical errors"
|
||||
- "**Foundation for similar problems**: This pattern extends to problems like 'Longest Univalue Path', 'Binary Tree Maximum Path Sum', and 'Path Sum' variants"
|
||||
|
||||
time_complexity: "O(n). We visit each node exactly once during the DFS traversal, where `n` is the number of nodes in the tree."
|
||||
space_complexity: "O(h). The recursion stack can grow up to the height of the tree `h`. In the worst case (skewed tree), this is O(n). For a balanced tree, it's O(log n)."
|
||||
|
||||
solutions:
|
||||
- approach_name: DFS with Path Maximum
|
||||
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 good_nodes(root: TreeNode) -> int:
|
||||
def dfs(node: TreeNode, max_so_far: int) -> int:
|
||||
# Base case: empty node contributes 0 good nodes
|
||||
if not node:
|
||||
return 0
|
||||
|
||||
# Check if current node is good
|
||||
good = 1 if node.val >= max_so_far else 0
|
||||
|
||||
# Update the path maximum for children
|
||||
new_max = max(max_so_far, node.val)
|
||||
|
||||
# Count good nodes in both subtrees
|
||||
left_count = dfs(node.left, new_max)
|
||||
right_count = dfs(node.right, new_max)
|
||||
|
||||
return good + left_count + right_count
|
||||
|
||||
# Start DFS from root with negative infinity
|
||||
# so root is always considered good
|
||||
return dfs(root, float('-inf'))
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — We visit each node exactly once.
|
||||
|
||||
**Space Complexity:** O(h) — Recursion stack depth equals tree height.
|
||||
|
||||
We traverse the tree using DFS, carrying the maximum value seen on the path from root. At each node, we check if it's good (value >= path max), then recursively process children with the updated maximum. The count bubbles up through the recursion.
|
||||
|
||||
- approach_name: BFS with Path Maximum
|
||||
is_optimal: false
|
||||
code: |
|
||||
from collections import deque
|
||||
|
||||
class TreeNode:
|
||||
def __init__(self, val=0, left=None, right=None):
|
||||
self.val = val
|
||||
self.left = left
|
||||
self.right = right
|
||||
|
||||
def good_nodes(root: TreeNode) -> int:
|
||||
if not root:
|
||||
return 0
|
||||
|
||||
count = 0
|
||||
# Queue stores (node, max_value_on_path_to_node)
|
||||
queue = deque([(root, float('-inf'))])
|
||||
|
||||
while queue:
|
||||
node, max_so_far = queue.popleft()
|
||||
|
||||
# Check if current node is good
|
||||
if node.val >= max_so_far:
|
||||
count += 1
|
||||
|
||||
# Update max for children
|
||||
new_max = max(max_so_far, node.val)
|
||||
|
||||
# Add children to queue with updated max
|
||||
if node.left:
|
||||
queue.append((node.left, new_max))
|
||||
if node.right:
|
||||
queue.append((node.right, new_max))
|
||||
|
||||
return count
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — We visit each node exactly once.
|
||||
|
||||
**Space Complexity:** O(w) — Queue size equals maximum width of tree. For a complete binary tree, this is O(n/2) = O(n).
|
||||
|
||||
BFS traverses level-by-level, but we still track the path maximum by storing it alongside each node in the queue. This approach uses iterative traversal instead of recursion, which can be preferable for very deep trees to avoid stack overflow.
|
||||
181
backend/data/questions/counting-bits.yaml
Normal file
181
backend/data/questions/counting-bits.yaml
Normal file
@@ -0,0 +1,181 @@
|
||||
title: Counting Bits
|
||||
slug: counting-bits
|
||||
difficulty: easy
|
||||
leetcode_id: 338
|
||||
leetcode_url: https://leetcode.com/problems/counting-bits/
|
||||
categories:
|
||||
- arrays
|
||||
- dynamic-programming
|
||||
- math
|
||||
patterns:
|
||||
- dynamic-programming
|
||||
|
||||
function_signature: "def count_bits(n: int) -> list[int]:"
|
||||
|
||||
test_cases:
|
||||
visible:
|
||||
- input: { n: 2 }
|
||||
expected: [0, 1, 1]
|
||||
- input: { n: 5 }
|
||||
expected: [0, 1, 1, 2, 1, 2]
|
||||
- input: { n: 0 }
|
||||
expected: [0]
|
||||
hidden:
|
||||
- input: { n: 1 }
|
||||
expected: [0, 1]
|
||||
- input: { n: 7 }
|
||||
expected: [0, 1, 1, 2, 1, 2, 2, 3]
|
||||
- input: { n: 10 }
|
||||
expected: [0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2]
|
||||
|
||||
description: |
|
||||
Given an integer `n`, return an array `ans` of length `n + 1` such that for each `i` (`0 <= i <= n`), `ans[i]` is the **number of `1`'s** in the binary representation of `i`.
|
||||
|
||||
constraints: |
|
||||
- `0 <= n <= 10^5`
|
||||
|
||||
examples:
|
||||
- input: "n = 2"
|
||||
output: "[0, 1, 1]"
|
||||
explanation: "0 → 0 (zero 1's), 1 → 1 (one 1), 2 → 10 (one 1)"
|
||||
- input: "n = 5"
|
||||
output: "[0, 1, 1, 2, 1, 2]"
|
||||
explanation: "0 → 0, 1 → 1, 2 → 10, 3 → 11, 4 → 100, 5 → 101"
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Imagine you're counting the number of `1` bits in binary for every number from `0` to `n`. The naive approach would be to convert each number to binary and count — but there's a beautiful pattern hiding in the numbers themselves.
|
||||
|
||||
Consider the relationship between a number and its half. When you divide a number by 2 (right shift), you simply remove the last bit. For example:
|
||||
- `5` in binary is `101` (two 1's)
|
||||
- `5 >> 1 = 2` in binary is `10` (one 1)
|
||||
|
||||
The only difference is whether the **last bit** (least significant bit) was a `1` or `0`. So if you already know how many 1's are in `n // 2`, you just need to check if `n` is odd (has a `1` in the last position).
|
||||
|
||||
This gives us the recurrence: `countBits(i) = countBits(i >> 1) + (i & 1)`
|
||||
|
||||
Think of it like building up your answers: once you know the bit count for smaller numbers, you can instantly compute it for larger ones by leveraging the relationship between a number and its right-shifted version.
|
||||
|
||||
approach: |
|
||||
We solve this using **Dynamic Programming** with a bit manipulation insight:
|
||||
|
||||
**Step 1: Create the result array**
|
||||
|
||||
- `ans`: Array of size `n + 1` initialised with zeros
|
||||
- `ans[0] = 0` since zero has no `1` bits (base case)
|
||||
|
||||
|
||||
|
||||
**Step 2: Build up using the recurrence relation**
|
||||
|
||||
- For each `i` from `1` to `n`:
|
||||
- `ans[i] = ans[i >> 1] + (i & 1)`
|
||||
- `i >> 1`: Right shift gives us the number with the last bit removed
|
||||
- `i & 1`: Checks if the current number is odd (last bit is `1`)
|
||||
|
||||
|
||||
|
||||
**Step 3: Return the result**
|
||||
|
||||
- Return `ans` containing the bit count for every number from `0` to `n`
|
||||
|
||||
|
||||
|
||||
The key insight is that `i >> 1` is always less than `i` (for `i > 0`), so we've already computed its answer. This allows us to solve the problem in a single pass with O(1) work per number.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Using Built-in Popcount for Each Number
|
||||
description: |
|
||||
A common first approach is to use `bin(i).count('1')` or `__builtin_popcount(i)` for each number from `0` to `n`.
|
||||
|
||||
While this works, it has **O(log i)** time per number (since each number has up to `log(i)` bits), giving overall **O(n log n)** time complexity.
|
||||
|
||||
The problem specifically asks for an O(n) solution, which requires recognising the DP pattern.
|
||||
wrong_approach: "Loop with bin(i).count('1') for each i"
|
||||
correct_approach: "Use DP recurrence: ans[i] = ans[i >> 1] + (i & 1)"
|
||||
|
||||
- title: Missing the Bit Shift Relationship
|
||||
description: |
|
||||
It's tempting to look for patterns like "powers of 2" or "consecutive numbers", but these don't lead to an elegant O(n) solution.
|
||||
|
||||
The key insight is that `i` and `i >> 1` differ by exactly one bit operation. Every bit in `i >> 1` was already in `i` (just shifted right), and we only need to account for the rightmost bit we "lost".
|
||||
|
||||
For example: `6 = 110` and `6 >> 1 = 3 = 11`. The bit counts are 2 and 2 respectively. Adding `6 & 1 = 0` gives us `2 + 0 = 2`. ✓
|
||||
wrong_approach: "Looking for complex mathematical patterns"
|
||||
correct_approach: "Recognise i >> 1 has same bits minus the LSB"
|
||||
|
||||
- title: Off-by-One in Array Size
|
||||
description: |
|
||||
The problem asks for numbers `0` through `n` inclusive, which means `n + 1` total numbers.
|
||||
|
||||
Creating an array of size `n` instead of `n + 1` will miss the last element or cause an index error.
|
||||
wrong_approach: "ans = [0] * n"
|
||||
correct_approach: "ans = [0] * (n + 1)"
|
||||
|
||||
key_takeaways:
|
||||
- "**Bit manipulation + DP**: Combining bit operations with dynamic programming often reveals elegant solutions"
|
||||
- "**Right shift insight**: `i >> 1` removes the last bit, so `countBits(i) = countBits(i >> 1) + (i & 1)`"
|
||||
- "**O(n) vs O(n log n)**: Recognising subproblem relationships can reduce complexity from O(n log n) to O(n)"
|
||||
- "**Build from smaller to larger**: Classic DP pattern — use already-computed answers for smaller inputs"
|
||||
|
||||
time_complexity: "O(n). We compute the answer for each number from `0` to `n` in constant time using the recurrence."
|
||||
space_complexity: "O(n). We store `n + 1` values in the result array (required by the problem output)."
|
||||
|
||||
solutions:
|
||||
- approach_name: Dynamic Programming with Bit Shift
|
||||
is_optimal: true
|
||||
code: |
|
||||
def count_bits(n: int) -> list[int]:
|
||||
# Result array: ans[i] will hold count of 1's in binary of i
|
||||
ans = [0] * (n + 1)
|
||||
|
||||
for i in range(1, n + 1):
|
||||
# i >> 1 removes last bit, i & 1 checks if last bit is 1
|
||||
# Since i >> 1 < i, we've already computed ans[i >> 1]
|
||||
ans[i] = ans[i >> 1] + (i & 1)
|
||||
|
||||
return ans
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Single pass from 1 to n with O(1) work each.
|
||||
|
||||
**Space Complexity:** O(n) — The output array of size n + 1.
|
||||
|
||||
This solution uses the recurrence `ans[i] = ans[i >> 1] + (i & 1)`. Right-shifting removes the last bit, and `i & 1` tells us if that bit was a `1`. Since we process numbers in order, `i >> 1` is always already computed.
|
||||
|
||||
- approach_name: Dynamic Programming with Last Set Bit
|
||||
is_optimal: true
|
||||
code: |
|
||||
def count_bits(n: int) -> list[int]:
|
||||
# Result array initialised with zeros
|
||||
ans = [0] * (n + 1)
|
||||
|
||||
for i in range(1, n + 1):
|
||||
# i & (i - 1) removes the rightmost set bit
|
||||
# So we add 1 to the count of the number without that bit
|
||||
ans[i] = ans[i & (i - 1)] + 1
|
||||
|
||||
return ans
|
||||
explanation: |
|
||||
**Time Complexity:** O(n) — Single pass with O(1) work per number.
|
||||
|
||||
**Space Complexity:** O(n) — The output array.
|
||||
|
||||
Alternative DP approach using `i & (i - 1)`, which clears the rightmost `1` bit. For example, `6 & 5 = 110 & 101 = 100 = 4`. Since `i & (i - 1) < i`, we've already computed its bit count, and we just add `1` for the bit we removed.
|
||||
|
||||
- approach_name: Built-in Popcount
|
||||
is_optimal: false
|
||||
code: |
|
||||
def count_bits(n: int) -> list[int]:
|
||||
ans = []
|
||||
|
||||
for i in range(n + 1):
|
||||
# Convert to binary string and count '1' characters
|
||||
ans.append(bin(i).count('1'))
|
||||
|
||||
return ans
|
||||
explanation: |
|
||||
**Time Complexity:** O(n log n) — For each number, counting bits takes O(log i) time.
|
||||
|
||||
**Space Complexity:** O(n) — The output array.
|
||||
|
||||
This straightforward approach uses Python's built-in functions but doesn't achieve the O(n) time requested in the follow-up. Useful for understanding the problem but not optimal.
|
||||
212
backend/data/questions/course-schedule-ii.yaml
Normal file
212
backend/data/questions/course-schedule-ii.yaml
Normal file
@@ -0,0 +1,212 @@
|
||||
title: Course Schedule II
|
||||
slug: course-schedule-ii
|
||||
difficulty: medium
|
||||
leetcode_id: 210
|
||||
leetcode_url: https://leetcode.com/problems/course-schedule-ii/
|
||||
categories:
|
||||
- graphs
|
||||
patterns:
|
||||
- bfs
|
||||
- dfs
|
||||
|
||||
description: |
|
||||
There are a total of `numCourses` courses you have to take, labelled from `0` to `numCourses - 1`. You are given an array `prerequisites` where `prerequisites[i] = [a_i, b_i]` indicates that you **must** take course `b_i` first if you want to take course `a_i`.
|
||||
|
||||
For example, the pair `[0, 1]` indicates that to take course `0` you have to first take course `1`.
|
||||
|
||||
Return *the ordering of courses you should take to finish all courses*. If there are many valid answers, return **any** of them. If it is impossible to finish all courses, return **an empty array**.
|
||||
|
||||
constraints: |
|
||||
- `1 <= numCourses <= 2000`
|
||||
- `0 <= prerequisites.length <= numCourses * (numCourses - 1)`
|
||||
- `prerequisites[i].length == 2`
|
||||
- `0 <= a_i, b_i < numCourses`
|
||||
- `a_i != b_i`
|
||||
- All the pairs `[a_i, b_i]` are **distinct**
|
||||
|
||||
examples:
|
||||
- input: "numCourses = 2, prerequisites = [[1,0]]"
|
||||
output: "[0,1]"
|
||||
explanation: "There are a total of 2 courses to take. To take course 1 you should have finished course 0. So the correct course order is [0,1]."
|
||||
- input: "numCourses = 4, prerequisites = [[1,0],[2,0],[3,1],[3,2]]"
|
||||
output: "[0,2,1,3]"
|
||||
explanation: "There are a total of 4 courses to take. To take course 3 you should have finished both courses 1 and 2. Both courses 1 and 2 should be taken after you finished course 0. So one correct course order is [0,1,2,3]. Another correct ordering is [0,2,1,3]."
|
||||
- input: "numCourses = 1, prerequisites = []"
|
||||
output: "[0]"
|
||||
explanation: "There is only one course with no prerequisites, so the order is simply [0]."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Imagine you're planning which university courses to take each semester. Some courses have prerequisites — you can't take Advanced Algorithms until you've completed Data Structures. This creates a natural ordering constraint.
|
||||
|
||||
The problem is asking us to find a **topological ordering** of the courses. Think of it like stacking books: you can only place a book on top of another if all its "dependencies" are already in the stack below. If there's a circular dependency (Book A requires Book B, and Book B requires Book A), it's impossible to stack them — we'd return an empty array.
|
||||
|
||||
The key insight is recognising this as a **directed graph problem**. Each course is a node, and each prerequisite `[a, b]` creates an edge from `b` to `a` (meaning "b must come before a"). We need to find an ordering where every edge points "forward" — that's exactly what topological sort does.
|
||||
|
||||
There are two classic approaches: **Kahn's Algorithm (BFS)** processes courses with no remaining prerequisites first, while **DFS-based** approach explores deeply and adds courses to the result in reverse post-order.
|
||||
|
||||
approach: |
|
||||
We'll use **Kahn's Algorithm (BFS)** — it's intuitive and naturally handles cycle detection:
|
||||
|
||||
**Step 1: Build the graph and calculate in-degrees**
|
||||
|
||||
- Create an adjacency list to represent the graph: for each prerequisite `[a, b]`, add `a` to the list of courses that depend on `b`
|
||||
- Calculate the **in-degree** of each course: how many prerequisites it has
|
||||
- In-degree tells us how many courses must be completed before we can take this one
|
||||
|
||||
|
||||
|
||||
**Step 2: Initialise the queue with "ready" courses**
|
||||
|
||||
- Add all courses with in-degree `0` to a queue — these have no prerequisites and can be taken immediately
|
||||
- These are our starting points
|
||||
|
||||
|
||||
|
||||
**Step 3: Process courses level by level (BFS)**
|
||||
|
||||
- Dequeue a course, add it to the result
|
||||
- For each course that depends on the dequeued course, decrement its in-degree
|
||||
- If any course's in-degree becomes `0`, it's now "unlocked" — add it to the queue
|
||||
- Repeat until the queue is empty
|
||||
|
||||
|
||||
|
||||
**Step 4: Check for cycles**
|
||||
|
||||
- If the result contains all `numCourses` courses, return it
|
||||
- If not, a cycle exists (some courses could never reach in-degree `0`) — return an empty array
|
||||
|
||||
|
||||
|
||||
This approach works because we only process a course when all its prerequisites have been processed, naturally building a valid ordering.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Cycle Detection Failure
|
||||
description: |
|
||||
The most critical edge case is detecting **cycles** in the prerequisites. If course A requires B, B requires C, and C requires A, no valid ordering exists.
|
||||
|
||||
With Kahn's Algorithm, cycles are detected automatically: courses in a cycle never reach in-degree `0`, so they're never added to the queue. If our result has fewer than `numCourses` entries, we know there's a cycle.
|
||||
|
||||
Forgetting this check would return an incomplete ordering instead of an empty array.
|
||||
wrong_approach: "Return partial ordering without checking length"
|
||||
correct_approach: "Verify result length equals numCourses"
|
||||
|
||||
- title: Confusing Edge Direction
|
||||
description: |
|
||||
The prerequisite `[a, b]` means "b must come before a" — the edge goes FROM `b` TO `a`. Getting this backwards inverts the entire dependency graph.
|
||||
|
||||
Think of it as: "to take course `a`, you need course `b` first." So `b` points to `a` (b enables a).
|
||||
wrong_approach: "Add edge from a to b"
|
||||
correct_approach: "Add edge from b to a (prerequisite points to dependent)"
|
||||
|
||||
- title: Handling Disconnected Components
|
||||
description: |
|
||||
Some courses might have no prerequisites AND no courses depending on them (isolated nodes). These must still appear in the result.
|
||||
|
||||
Initialising in-degrees for all courses from `0` to `numCourses - 1` (not just those in prerequisites) ensures isolated courses start with in-degree `0` and get processed.
|
||||
wrong_approach: "Only track courses mentioned in prerequisites"
|
||||
correct_approach: "Initialise in-degree array for all numCourses courses"
|
||||
|
||||
- title: Multiple Valid Orderings
|
||||
description: |
|
||||
The problem states any valid ordering is acceptable. Don't over-engineer to find a "canonical" order — the first valid topological sort you find works.
|
||||
|
||||
If courses 1 and 2 both have in-degree 0 at the same time, either can come first in the result.
|
||||
|
||||
key_takeaways:
|
||||
- "**Topological sort** produces a linear ordering of vertices in a DAG where every edge points from earlier to later in the sequence"
|
||||
- "**Kahn's Algorithm** uses BFS with in-degrees: start with in-degree 0 nodes, decrement neighbours' in-degrees, repeat"
|
||||
- "**Cycle detection** is built-in: if the result has fewer nodes than the graph, a cycle exists"
|
||||
- "This pattern applies to **dependency resolution** problems: build systems, task scheduling, package managers"
|
||||
|
||||
time_complexity: "O(V + E). We process each course (vertex) once and examine each prerequisite (edge) once when decrementing in-degrees. Here V = `numCourses` and E = `len(prerequisites)`."
|
||||
space_complexity: "O(V + E). The adjacency list stores all edges, and we use arrays of size V for in-degrees and the result. The queue holds at most V courses."
|
||||
|
||||
solutions:
|
||||
- approach_name: Kahn's Algorithm (BFS)
|
||||
is_optimal: true
|
||||
code: |
|
||||
from collections import deque
|
||||
|
||||
def find_order(num_courses: int, prerequisites: list[list[int]]) -> list[int]:
|
||||
# Build adjacency list and calculate in-degrees
|
||||
graph = [[] for _ in range(num_courses)]
|
||||
in_degree = [0] * num_courses
|
||||
|
||||
for course, prereq in prerequisites:
|
||||
graph[prereq].append(course) # prereq -> course edge
|
||||
in_degree[course] += 1 # course has one more prerequisite
|
||||
|
||||
# Start with courses that have no prerequisites
|
||||
queue = deque()
|
||||
for course in range(num_courses):
|
||||
if in_degree[course] == 0:
|
||||
queue.append(course)
|
||||
|
||||
result = []
|
||||
|
||||
# Process courses in topological order
|
||||
while queue:
|
||||
current = queue.popleft()
|
||||
result.append(current)
|
||||
|
||||
# "Unlock" courses that depended on current
|
||||
for next_course in graph[current]:
|
||||
in_degree[next_course] -= 1
|
||||
if in_degree[next_course] == 0:
|
||||
queue.append(next_course)
|
||||
|
||||
# If we processed all courses, return the order; otherwise cycle exists
|
||||
return result if len(result) == num_courses else []
|
||||
explanation: |
|
||||
**Time Complexity:** O(V + E) — Each course is enqueued/dequeued once, and each prerequisite edge is processed once.
|
||||
|
||||
**Space Complexity:** O(V + E) — Adjacency list stores all edges, plus O(V) for in-degrees, queue, and result.
|
||||
|
||||
Kahn's Algorithm processes courses in dependency order. By tracking in-degrees, we know exactly when a course becomes "ready" (all prerequisites completed). The BFS naturally produces a valid topological ordering.
|
||||
|
||||
- approach_name: DFS with Cycle Detection
|
||||
is_optimal: true
|
||||
code: |
|
||||
def find_order(num_courses: int, prerequisites: list[list[int]]) -> list[int]:
|
||||
# Build adjacency list
|
||||
graph = [[] for _ in range(num_courses)]
|
||||
for course, prereq in prerequisites:
|
||||
graph[prereq].append(course)
|
||||
|
||||
# States: 0 = unvisited, 1 = visiting (in current path), 2 = visited
|
||||
state = [0] * num_courses
|
||||
result = []
|
||||
|
||||
def dfs(course: int) -> bool:
|
||||
"""Returns True if no cycle detected, False if cycle found."""
|
||||
if state[course] == 1: # Currently visiting -> cycle!
|
||||
return False
|
||||
if state[course] == 2: # Already processed
|
||||
return True
|
||||
|
||||
state[course] = 1 # Mark as visiting
|
||||
|
||||
for next_course in graph[course]:
|
||||
if not dfs(next_course):
|
||||
return False
|
||||
|
||||
state[course] = 2 # Mark as fully processed
|
||||
result.append(course) # Add in reverse post-order
|
||||
return True
|
||||
|
||||
# Run DFS from each unvisited course
|
||||
for course in range(num_courses):
|
||||
if state[course] == 0:
|
||||
if not dfs(course):
|
||||
return []
|
||||
|
||||
# Result is in reverse order (we added nodes after processing children)
|
||||
return result[::-1]
|
||||
explanation: |
|
||||
**Time Complexity:** O(V + E) — Each course is visited once, and each edge is traversed once.
|
||||
|
||||
**Space Complexity:** O(V + E) — Adjacency list plus O(V) recursion stack in worst case.
|
||||
|
||||
DFS explores each course deeply, adding it to the result only after all its dependents are processed. The three-state system (unvisited, visiting, visited) detects cycles: if we revisit a node that's currently in our path, we've found a cycle. The final result is reversed because we add nodes in post-order (after children).
|
||||
194
backend/data/questions/course-schedule-iv.yaml
Normal file
194
backend/data/questions/course-schedule-iv.yaml
Normal file
@@ -0,0 +1,194 @@
|
||||
title: Course Schedule IV
|
||||
slug: course-schedule-iv
|
||||
difficulty: medium
|
||||
leetcode_id: 1462
|
||||
leetcode_url: https://leetcode.com/problems/course-schedule-iv/
|
||||
categories:
|
||||
- graphs
|
||||
patterns:
|
||||
- bfs
|
||||
- dfs
|
||||
- dynamic-programming
|
||||
|
||||
description: |
|
||||
There are a total of `numCourses` courses you have to take, labeled from `0` to `numCourses - 1`. You are given an array `prerequisites` where `prerequisites[i] = [a_i, b_i]` indicates that you **must** take course `a_i` first if you want to take course `b_i`.
|
||||
|
||||
For example, the pair `[0, 1]` indicates that you have to take course `0` before you can take course `1`.
|
||||
|
||||
Prerequisites can also be **indirect**. If course `a` is a prerequisite of course `b`, and course `b` is a prerequisite of course `c`, then course `a` is a prerequisite of course `c`.
|
||||
|
||||
You are also given an array `queries` where `queries[j] = [u_j, v_j]`. For the j<sup>th</sup> query, you should answer whether course `u_j` is a prerequisite of course `v_j` or not.
|
||||
|
||||
Return *a boolean array* `answer`, *where* `answer[j]` *is the answer to the* j<sup>th</sup> *query*.
|
||||
|
||||
constraints: |
|
||||
- `2 <= numCourses <= 100`
|
||||
- `0 <= prerequisites.length <= (numCourses * (numCourses - 1) / 2)`
|
||||
- `prerequisites[i].length == 2`
|
||||
- `0 <= a_i, b_i <= numCourses - 1`
|
||||
- `a_i != b_i`
|
||||
- All the pairs `[a_i, b_i]` are **unique**
|
||||
- The prerequisites graph has no cycles
|
||||
- `1 <= queries.length <= 10^4`
|
||||
- `0 <= u_i, v_i <= numCourses - 1`
|
||||
- `u_i != v_i`
|
||||
|
||||
examples:
|
||||
- input: "numCourses = 2, prerequisites = [[1,0]], queries = [[0,1],[1,0]]"
|
||||
output: "[false, true]"
|
||||
explanation: "The pair [1, 0] indicates that you have to take course 1 before you can take course 0. Course 0 is not a prerequisite of course 1, but the opposite is true."
|
||||
- input: "numCourses = 2, prerequisites = [], queries = [[1,0],[0,1]]"
|
||||
output: "[false, false]"
|
||||
explanation: "There are no prerequisites, and each course is independent."
|
||||
- input: "numCourses = 3, prerequisites = [[1,2],[1,0],[2,0]], queries = [[1,0],[1,2]]"
|
||||
output: "[true, true]"
|
||||
explanation: "Course 1 is a direct prerequisite of both course 2 and course 0. Course 2 is also a prerequisite of course 0, but for these queries we only need to check if course 1 is a prerequisite."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Think of the courses as cities and prerequisites as one-way roads. The question "Is course `u` a prerequisite of course `v`?" is really asking: **Can you travel from city `u` to city `v` following the roads?**
|
||||
|
||||
This is the classic **graph reachability** problem. Given a directed acyclic graph (DAG), we need to precompute which nodes can reach which other nodes, then answer multiple queries efficiently.
|
||||
|
||||
The key insight is that with `numCourses <= 100` and potentially `10^4` queries, we can afford to precompute **all** reachability information upfront. If we had to run a BFS/DFS for each query, we'd do O(queries * (V + E)) work. Instead, we can precompute a reachability matrix in O(V^3) or O(V * (V + E)) time, then answer each query in O(1).
|
||||
|
||||
The **Floyd-Warshall** algorithm is perfect here: it computes transitive closure (all-pairs reachability) by considering whether any intermediate node `k` can connect node `i` to node `j`. If `i` can reach `k` and `k` can reach `j`, then `i` can reach `j`.
|
||||
|
||||
approach: |
|
||||
We solve this using **Floyd-Warshall Transitive Closure**:
|
||||
|
||||
**Step 1: Initialise the reachability matrix**
|
||||
|
||||
- Create an `n x n` boolean matrix `reachable` where `reachable[i][j]` means "course `i` is a prerequisite of course `j`"
|
||||
- Initialise all values to `False`
|
||||
- For each direct prerequisite `[a, b]`, set `reachable[a][b] = True`
|
||||
|
||||
|
||||
|
||||
**Step 2: Apply Floyd-Warshall algorithm**
|
||||
|
||||
- For each intermediate course `k` from `0` to `n-1`:
|
||||
- For each pair of courses `(i, j)`:
|
||||
- If `i` can reach `k` AND `k` can reach `j`, then `i` can reach `j`
|
||||
- Update: `reachable[i][j] = reachable[i][j] OR (reachable[i][k] AND reachable[k][j])`
|
||||
|
||||
|
||||
|
||||
**Step 3: Answer queries**
|
||||
|
||||
- For each query `[u, v]`, simply look up `reachable[u][v]`
|
||||
- Return the list of boolean answers
|
||||
|
||||
|
||||
|
||||
The Floyd-Warshall approach works because it systematically considers all possible "stepping stones" between any two nodes. After processing all intermediate nodes, the matrix contains complete transitive closure information.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Running BFS/DFS Per Query
|
||||
description: |
|
||||
A naive approach runs a graph traversal for each query to check if `u` can reach `v`. With up to `10^4` queries and O(V + E) per traversal, this becomes O(queries * (V + E)).
|
||||
|
||||
While this might pass given the small graph size (V <= 100), it's unnecessarily slow. Precomputing reachability once in O(V^3) or O(V * (V + E)) is more efficient when there are many queries.
|
||||
wrong_approach: "BFS/DFS for each query"
|
||||
correct_approach: "Precompute all reachability with Floyd-Warshall"
|
||||
|
||||
- title: Confusing Edge Direction
|
||||
description: |
|
||||
The problem states `prerequisites[i] = [a, b]` means "you must take course `a` first to take course `b`". This means there's a directed edge **from `a` to `b`**.
|
||||
|
||||
A query `[u, v]` asks if `u` is a prerequisite of `v`, meaning "can we reach `v` starting from `u`?" Make sure your edge direction matches: `reachable[a][b] = True` for prerequisite `[a, b]`.
|
||||
wrong_approach: "Edge from b to a (reversed)"
|
||||
correct_approach: "Edge from a to b (a is prerequisite of b)"
|
||||
|
||||
- title: Forgetting Transitive Relationships
|
||||
description: |
|
||||
Don't only consider direct prerequisites. If `A -> B` and `B -> C`, then `A` is also a prerequisite of `C` even though `[A, C]` isn't in the input.
|
||||
|
||||
The whole point of Floyd-Warshall (or DFS from each node) is to propagate these indirect relationships. Simply checking if `[u, v]` exists in the prerequisites list will give wrong answers.
|
||||
wrong_approach: "Only check direct prerequisites"
|
||||
correct_approach: "Compute transitive closure"
|
||||
|
||||
key_takeaways:
|
||||
- "**Transitive closure**: Floyd-Warshall computes all-pairs reachability in O(V^3), ideal when V is small but queries are many"
|
||||
- "**Precomputation tradeoff**: When you have many queries on static data, precompute everything upfront for O(1) query time"
|
||||
- "**Alternative approach**: BFS/DFS from each node also works in O(V * (V + E)) and may be faster for sparse graphs"
|
||||
- "**Graph representation**: This pattern applies to any reachability problem - dependency resolution, inheritance hierarchies, social network connections"
|
||||
|
||||
time_complexity: "O(V^3 + Q). Floyd-Warshall takes O(V^3) where V is `numCourses`, and answering Q queries takes O(Q). With V <= 100 and Q <= 10^4, this is efficient."
|
||||
space_complexity: "O(V^2). We store an `n x n` reachability matrix where n is `numCourses`."
|
||||
|
||||
solutions:
|
||||
- approach_name: Floyd-Warshall Transitive Closure
|
||||
is_optimal: true
|
||||
code: |
|
||||
def checkIfPrerequisite(
|
||||
numCourses: int,
|
||||
prerequisites: list[list[int]],
|
||||
queries: list[list[int]]
|
||||
) -> list[bool]:
|
||||
n = numCourses
|
||||
# Initialise reachability matrix
|
||||
# reachable[i][j] = True means course i is a prerequisite of course j
|
||||
reachable = [[False] * n for _ in range(n)]
|
||||
|
||||
# Mark direct prerequisites
|
||||
for a, b in prerequisites:
|
||||
reachable[a][b] = True
|
||||
|
||||
# Floyd-Warshall: propagate reachability through intermediate nodes
|
||||
for k in range(n):
|
||||
for i in range(n):
|
||||
for j in range(n):
|
||||
# If i reaches k and k reaches j, then i reaches j
|
||||
if reachable[i][k] and reachable[k][j]:
|
||||
reachable[i][j] = True
|
||||
|
||||
# Answer each query in O(1)
|
||||
return [reachable[u][v] for u, v in queries]
|
||||
explanation: |
|
||||
**Time Complexity:** O(V^3 + Q) — Floyd-Warshall runs in O(V^3), then each of Q queries is O(1).
|
||||
|
||||
**Space Complexity:** O(V^2) — The reachability matrix stores n^2 boolean values.
|
||||
|
||||
Floyd-Warshall systematically considers each node `k` as a potential intermediate step. If we can reach `k` from `i` and reach `j` from `k`, we know `i` can reach `j`. After checking all intermediate nodes, we have complete transitive closure.
|
||||
|
||||
- approach_name: BFS from Each Node
|
||||
is_optimal: false
|
||||
code: |
|
||||
from collections import deque
|
||||
|
||||
def checkIfPrerequisite(
|
||||
numCourses: int,
|
||||
prerequisites: list[list[int]],
|
||||
queries: list[list[int]]
|
||||
) -> list[bool]:
|
||||
n = numCourses
|
||||
# Build adjacency list
|
||||
graph = [[] for _ in range(n)]
|
||||
for a, b in prerequisites:
|
||||
graph[a].append(b)
|
||||
|
||||
# Precompute reachability using BFS from each node
|
||||
reachable = [[False] * n for _ in range(n)]
|
||||
|
||||
for start in range(n):
|
||||
# BFS to find all nodes reachable from start
|
||||
visited = [False] * n
|
||||
queue = deque([start])
|
||||
visited[start] = True
|
||||
|
||||
while queue:
|
||||
node = queue.popleft()
|
||||
for neighbor in graph[node]:
|
||||
if not visited[neighbor]:
|
||||
visited[neighbor] = True
|
||||
reachable[start][neighbor] = True
|
||||
queue.append(neighbor)
|
||||
|
||||
return [reachable[u][v] for u, v in queries]
|
||||
explanation: |
|
||||
**Time Complexity:** O(V * (V + E) + Q) — BFS from each of V nodes, each BFS is O(V + E), then Q queries in O(1).
|
||||
|
||||
**Space Complexity:** O(V^2 + V + E) — Reachability matrix O(V^2), adjacency list O(V + E), BFS queue O(V).
|
||||
|
||||
This approach builds an adjacency list and runs BFS from each node to find all reachable nodes. For sparse graphs where E << V^2, this can be faster than Floyd-Warshall. Both approaches precompute full reachability for O(1) query answering.
|
||||
251
backend/data/questions/course-schedule.yaml
Normal file
251
backend/data/questions/course-schedule.yaml
Normal file
@@ -0,0 +1,251 @@
|
||||
title: Course Schedule
|
||||
slug: course-schedule
|
||||
difficulty: medium
|
||||
leetcode_id: 207
|
||||
leetcode_url: https://leetcode.com/problems/course-schedule/
|
||||
categories:
|
||||
- graphs
|
||||
patterns:
|
||||
- dfs
|
||||
- bfs
|
||||
|
||||
description: |
|
||||
There are a total of `numCourses` courses you have to take, labeled from `0` to `numCourses - 1`. You are given an array `prerequisites` where `prerequisites[i] = [a`<sub>`i`</sub>`, b`<sub>`i`</sub>`]` indicates that you **must** take course `b`<sub>`i`</sub> first if you want to take course `a`<sub>`i`</sub>.
|
||||
|
||||
For example, the pair `[0, 1]` indicates that to take course `0` you have to first take course `1`.
|
||||
|
||||
Return `true` *if you can finish all courses*. Otherwise, return `false`.
|
||||
|
||||
constraints: |
|
||||
- `1 <= numCourses <= 2000`
|
||||
- `0 <= prerequisites.length <= 5000`
|
||||
- `prerequisites[i].length == 2`
|
||||
- `0 <= a`<sub>`i`</sub>`, b`<sub>`i`</sub>` < numCourses`
|
||||
- All the pairs `prerequisites[i]` are **unique**
|
||||
|
||||
examples:
|
||||
- input: "numCourses = 2, prerequisites = [[1,0]]"
|
||||
output: "true"
|
||||
explanation: "There are a total of 2 courses to take. To take course 1 you should have finished course 0. So it is possible."
|
||||
- input: "numCourses = 2, prerequisites = [[1,0],[0,1]]"
|
||||
output: "false"
|
||||
explanation: "There are a total of 2 courses to take. To take course 1 you should have finished course 0, and to take course 0 you should also have finished course 1. So it is impossible."
|
||||
|
||||
explanation:
|
||||
intuition: |
|
||||
Imagine you're planning which courses to take in university. Some courses have prerequisites — you can't take Advanced Calculus without first completing Calculus 101. The question is: given all these dependency rules, is there a valid order to take all courses?
|
||||
|
||||
This is fundamentally a **cycle detection problem** in a directed graph. Each course is a node, and each prerequisite creates a directed edge from the required course to the dependent course. If you can find an ordering where all prerequisites are satisfied, that ordering is called a **topological sort**.
|
||||
|
||||
The key insight is: **a valid ordering exists if and only if the graph has no cycles**. Why? If there's a cycle like A → B → C → A, then A requires B, B requires C, and C requires A — an impossible circular dependency where no course can be taken first.
|
||||
|
||||
Think of it like this: you're trying to complete tasks where some tasks depend on others. If task A depends on B, and B depends on A, you're stuck in an infinite waiting loop — neither can start.
|
||||
|
||||
approach: |
|
||||
We can solve this using **DFS with cycle detection** (also known as detecting back edges). The idea is to track the state of each node during traversal:
|
||||
|
||||
**Step 1: Build the adjacency list**
|
||||
|
||||
- Create a graph where `graph[course]` contains all courses that depend on `course`
|
||||
- For each prerequisite `[a, b]`, add an edge from `b` to `a` (course `b` must be taken before course `a`)
|
||||
|
||||
|
||||
|
||||
**Step 2: Set up state tracking**
|
||||
|
||||
- `WHITE (0)`: Not visited yet
|
||||
- `GRAY (1)`: Currently being processed (in the current DFS path)
|
||||
- `BLACK (2)`: Completely processed (all descendants visited)
|
||||
|
||||
|
||||
|
||||
**Step 3: DFS from each unvisited node**
|
||||
|
||||
- Mark the current node as `GRAY` (we're exploring it)
|
||||
- Visit all its neighbours recursively
|
||||
- If we encounter a `GRAY` node, we've found a cycle — return `False`
|
||||
- After exploring all neighbours, mark the node as `BLACK`
|
||||
- If we complete without finding a cycle, return `True`
|
||||
|
||||
|
||||
|
||||
**Step 4: Return the result**
|
||||
|
||||
- If DFS completes for all nodes without detecting a cycle, return `True`
|
||||
- Otherwise, return `False`
|
||||
|
||||
|
||||
|
||||
The three-colour approach is crucial: `GRAY` nodes indicate "we're currently on this path", so encountering a `GRAY` node means we've looped back — a cycle.
|
||||
|
||||
common_pitfalls:
|
||||
- title: Confusing "Visited" with "In Current Path"
|
||||
description: |
|
||||
A simple visited set isn't enough for cycle detection in directed graphs. Consider:
|
||||
```
|
||||
A → B → C
|
||||
A → C
|
||||
```
|
||||
When exploring A → C directly, node C might already be visited from the A → B → C path. But that's not a cycle!
|
||||
|
||||
The key distinction is:
|
||||
- **Visited (BLACK)**: We've fully explored this node and all its descendants — safe to skip
|
||||
- **In current path (GRAY)**: We're currently exploring this node — encountering it again means a cycle
|
||||
|
||||
Using only a visited set would incorrectly report cycles or miss them entirely.
|
||||
wrong_approach: "Single visited set for all nodes"
|
||||
correct_approach: "Three-state tracking (WHITE/GRAY/BLACK)"
|
||||
|
||||
- title: Wrong Edge Direction
|
||||
description: |
|
||||
The prerequisite format `[a, b]` means "to take `a`, you must first take `b`". This creates a dependency edge from `b` to `a`.
|
||||
|
||||
If you build the graph with edges pointing the wrong direction, your cycle detection will still work, but the semantics will be inverted.
|
||||
|
||||
For this problem, either direction works for cycle detection, but getting it right matters for follow-up problems like Course Schedule II where you need the actual ordering.
|
||||
wrong_approach: "Adding edge from a to b"
|
||||
correct_approach: "Adding edge from b to a (prerequisite points to dependent)"
|
||||
|
||||
- title: Not Checking All Components
|
||||
description: |
|
||||
The graph might be disconnected — some courses may have no prerequisites and no dependents. If you only start DFS from one node, you might miss cycles in other components.
|
||||
|
||||
Always iterate through all nodes and run DFS on any unvisited node.
|
||||
wrong_approach: "DFS from only node 0"
|
||||
correct_approach: "DFS from every unvisited node"
|
||||
|
||||
key_takeaways:
|
||||
- "**Cycle detection pattern**: Use three states (unvisited/processing/done) to detect back edges in directed graphs"
|
||||
- "**Graph modelling skill**: Recognising that dependency problems map to directed graph cycle detection is a key insight"
|
||||
- "**Topological sort foundation**: No cycles means a topological ordering exists — this is the basis for Course Schedule II"
|
||||
- "**DFS vs BFS**: Both work here. DFS with colouring is elegant; BFS with in-degree counting (Kahn's algorithm) is an alternative approach"
|
||||
|
||||
time_complexity: "O(V + E). We visit each node once and traverse each edge once, where V is `numCourses` and E is the number of prerequisites."
|
||||
space_complexity: "O(V + E). We store the adjacency list (O(E)) and the state array (O(V)), plus recursion stack space (O(V) in worst case)."
|
||||
|
||||
solutions:
|
||||
- approach_name: DFS with Cycle Detection
|
||||
is_optimal: true
|
||||
code: |
|
||||
def can_finish(num_courses: int, prerequisites: list[list[int]]) -> bool:
|
||||
# Build adjacency list: graph[b] = [courses that require b]
|
||||
graph = [[] for _ in range(num_courses)]
|
||||
for course, prereq in prerequisites:
|
||||
graph[prereq].append(course)
|
||||
|
||||
# States: 0 = unvisited, 1 = visiting (in current path), 2 = visited
|
||||
state = [0] * num_courses
|
||||
|
||||
def has_cycle(node: int) -> bool:
|
||||
if state[node] == 1: # Found a back edge - cycle detected!
|
||||
return True
|
||||
if state[node] == 2: # Already fully processed - no cycle here
|
||||
return False
|
||||
|
||||
state[node] = 1 # Mark as currently visiting
|
||||
|
||||
# Check all dependent courses
|
||||
for neighbour in graph[node]:
|
||||
if has_cycle(neighbour):
|
||||
return True
|
||||
|
||||
state[node] = 2 # Mark as fully processed
|
||||
return False
|
||||
|
||||
# Check for cycles starting from each course
|
||||
for course in range(num_courses):
|
||||
if has_cycle(course):
|
||||
return False
|
||||
|
||||
return True
|
||||
explanation: |
|
||||
**Time Complexity:** O(V + E) — Each node and edge is visited once.
|
||||
|
||||
**Space Complexity:** O(V + E) — Adjacency list storage plus recursion stack.
|
||||
|
||||
We use DFS with three-state colouring to detect cycles. A node in state `1` (visiting) that we encounter again indicates a back edge, meaning we've found a cycle. If we complete DFS on all nodes without finding a cycle, a valid course ordering exists.
|
||||
|
||||
- approach_name: BFS with In-degree (Kahn's Algorithm)
|
||||
is_optimal: true
|
||||
code: |
|
||||
from collections import deque
|
||||
|
||||
def can_finish(num_courses: int, prerequisites: list[list[int]]) -> bool:
|
||||
# Build adjacency list and count in-degrees
|
||||
graph = [[] for _ in range(num_courses)]
|
||||
in_degree = [0] * num_courses
|
||||
|
||||
for course, prereq in prerequisites:
|
||||
graph[prereq].append(course)
|
||||
in_degree[course] += 1
|
||||
|
||||
# Start with courses that have no prerequisites
|
||||
queue = deque()
|
||||
for course in range(num_courses):
|
||||
if in_degree[course] == 0:
|
||||
queue.append(course)
|
||||
|
||||
courses_taken = 0
|
||||
|
||||
while queue:
|
||||
course = queue.popleft()
|
||||
courses_taken += 1
|
||||
|
||||
# "Complete" this course - reduce in-degree of dependent courses
|
||||
for dependent in graph[course]:
|
||||
in_degree[dependent] -= 1
|
||||
# If all prerequisites met, this course is now available
|
||||
if in_degree[dependent] == 0:
|
||||
queue.append(dependent)
|
||||
|
||||
# If we took all courses, no cycle exists
|
||||
return courses_taken == num_courses
|
||||
explanation: |
|
||||
**Time Complexity:** O(V + E) — Process each node and edge once.
|
||||
|
||||
**Space Complexity:** O(V + E) — Adjacency list, in-degree array, and queue.
|
||||
|
||||
Kahn's algorithm takes a different approach: start with courses that have no prerequisites (in-degree 0), "complete" them, and see which courses become available. If we can complete all courses, no cycle exists. If some courses remain with non-zero in-degree, they're part of a cycle.
|
||||
|
||||
- approach_name: DFS with Visited Set (Simplified)
|
||||
is_optimal: false
|
||||
code: |
|
||||
def can_finish(num_courses: int, prerequisites: list[list[int]]) -> bool:
|
||||
# Build adjacency list
|
||||
graph = [[] for _ in range(num_courses)]
|
||||
for course, prereq in prerequisites:
|
||||
graph[prereq].append(course)
|
||||
|
||||
# Track globally visited and current path
|
||||
visited = set()
|
||||
path = set()
|
||||
|
||||
def dfs(node: int) -> bool:
|
||||
if node in path: # Cycle detected
|
||||
return False
|
||||
if node in visited: # Already processed
|
||||
return True
|
||||
|
||||
path.add(node) # Add to current path
|
||||
|
||||
for neighbour in graph[node]:
|
||||
if not dfs(neighbour):
|
||||
return False
|
||||
|
||||
path.remove(node) # Remove from current path
|
||||
visited.add(node) # Mark as fully visited
|
||||
|
||||
return True
|
||||
|
||||
# Check all courses
|
||||
for course in range(num_courses):
|
||||
if not dfs(course):
|
||||
return False
|
||||
|
||||
return True
|
||||
explanation: |
|
||||
**Time Complexity:** O(V + E) — Same as the array-based approach.
|
||||
|
||||
**Space Complexity:** O(V) — Two sets instead of an array.
|
||||
|
||||
This uses sets instead of a state array, which some find more intuitive. The `path` set tracks the current DFS path (equivalent to state `1`), and `visited` tracks fully processed nodes (equivalent to state `2`). Functionally identical to the optimal solution.
|
||||
Reference in New Issue
Block a user