feat(content): test cases batch 1

This commit is contained in:
2025-05-24 20:55:37 +01:00
parent 1def615b9e
commit 9b8f91ab19
4 changed files with 467 additions and 181 deletions

View File

@@ -9,81 +9,164 @@ categories:
patterns:
- greedy
function_signature: "def max_profit(prices: list[int]) -> int:"
test_cases:
visible:
- input: { prices: [7, 1, 5, 3, 6, 4] }
expected: 5
- input: { prices: [7, 6, 4, 3, 1] }
expected: 0
hidden:
- input: { prices: [1, 2, 3, 4, 5] }
expected: 4
- input: { prices: [2, 4, 1] }
expected: 2
- input: { prices: [3, 3, 3, 3] }
expected: 0
- input: { prices: [1] }
expected: 0
description: |
You are given an array `prices` where `prices[i]` is the price of a given stock on the ith day.
You are given an array `prices` where `prices[i]` is the price of a given stock on the i<sup>th</sup> day.
You want to maximize your profit by choosing a single day to buy one stock and choosing a
different day in the future to sell that stock.
You want to maximize your profit by choosing a **single day** to buy one stock and choosing a **different day in the future** to sell that stock.
Return the maximum profit you can achieve from this transaction. If you cannot achieve any
profit, return 0.
Return *the maximum profit you can achieve from this transaction*. If you cannot achieve any profit, return `0`.
constraints: |
- 1 <= prices.length <= 10^5
- 0 <= prices[i] <= 10^4
- `1 <= prices.length <= 10^5`
- `0 <= prices[i] <= 10^4`
examples:
- input: "prices = [7,1,5,3,6,4]"
output: "5"
explanation: "Buy on day 2 (price = 1) and sell on day 5 (price = 6), profit = 6-1 = 5."
explanation: "Buy on day 2 (price = 1) and sell on day 5 (price = 6), profit = 6 - 1 = 5. Note that buying on day 2 and selling on day 1 is not allowed because you must buy before you sell."
- input: "prices = [7,6,4,3,1]"
output: "0"
explanation: "No profitable transaction possible."
explanation: "In this case, no transactions are done and the max profit = 0."
explanation:
approach: |
1. Track the minimum price seen so far
2. For each day, calculate the profit if we sold today
3. Update maximum profit if current profit is higher
4. Update minimum price if current price is lower
intuition: |
To maximize profit, we want to buy at the lowest price and sell at the highest price after
that. Rather than comparing all pairs (O(n²)), we track the minimum price seen so far.
Imagine visualising the stock prices as a line graph over time.
For each day, we ask: "If I sold today, what's the maximum profit?" This is simply
today's price minus the minimum price we've seen before today.
Your goal is to find the **largest vertical distance** between a "valley" (lowest price) and a subsequent "peak" (highest price). The key constraint is the direction of time: you must buy *before* you sell. Therefore, you cannot simply pick the global minimum and maximum from the entire array — the minimum must appear at an earlier index than the selling price.
Think of it like this: as you walk through the days one by one, you want to keep track of the **lowest price you have seen so far**. For every new day, you ask yourself: "If I had bought at that lowest price and sold today, how much profit would I make?"
By continuously tracking this running minimum and comparing potential profits, you can find the optimal answer in a single pass.
approach: |
We solve this using a **Single Pass (Greedy) Approach**:
**Step 1: Initialise two variables**
- `min_price`: Set to infinity initially, so the first price we encounter becomes the minimum
- `max_profit`: Set to `0`, since we return `0` if no profit is possible
&nbsp;
**Step 2: Iterate through the prices array**
- For each price, check if it's lower than our current `min_price`
- If yes, update `min_price` — this ensures we're always considering buying at the lowest point found so far
- If no, calculate the potential profit: `current_price - min_price`
- Compare this with `max_profit` and keep the larger value
&nbsp;
**Step 3: Return the result**
- Return `max_profit` after checking all days
&nbsp;
This greedy approach works because at each step we make the locally optimal choice (tracking the minimum and maximum profit so far), which leads to the globally optimal solution.
common_pitfalls:
- title: Selling before buying
- title: The Brute Force Trap
description: |
Ensure you only consider selling on days after the minimum price was observed.
Tracking minimum as you iterate handles this automatically.
wrong_approach: "Using global min and max without considering order"
correct_approach: "Track running minimum, calculate profit from that point"
A common first instinct is to use nested loops to compare every pair of days:
- Outer loop `i` from `0` to `n-1` (buy day)
- Inner loop `j` from `i+1` to `n-1` (sell day)
- title: Initializing min_price to 0
This results in **O(n²) time complexity**. With the constraint `prices.length <= 10^5`, an O(n²) solution means up to 10 billion operations — this will cause a **Time Limit Exceeded (TLE)** error.
wrong_approach: "Nested loops comparing all pairs"
correct_approach: "Single pass tracking running minimum"
- title: Buying After Selling
description: |
Initialize min_price to the first element or infinity, not 0, since prices are positive
and you need to track actual minimum.
Don't find the global minimum and global maximum independently without considering their order.
For example, with `prices = [5, 4, 3, 2, 1]`, the minimum is `1` (last day) and maximum is `5` (first day). But you can't sell on day 1 and buy on day 5 — time only moves forward!
By tracking `min_price` as we iterate left-to-right, we automatically ensure we only consider selling *after* buying.
wrong_approach: "min(prices) and max(prices) separately"
correct_approach: "Track running minimum while iterating forward"
- title: Forgetting the No-Profit Case
description: |
When prices strictly decrease (e.g., `[7, 6, 4, 3, 1]`), no profitable transaction exists. Your algorithm should return `0`, not a negative number.
Initialising `max_profit = 0` handles this automatically — we only update it when we find a positive profit.
key_takeaways:
- Single pass through array is sufficient
- Track running minimum for optimal buy point
- Greedy approach works when you can only make one transaction
- This is a foundation for more complex stock problems
- "**Greedy pattern**: Make locally optimal choices (update min/max) at each step to find the global optimum"
- "**One-pass efficiency**: When looking for maximum differences in sequential data, try maintaining running variables rather than re-scanning"
- "**Foundation for harder problems**: This logic extends to variants like multiple transactions, cooldown periods, or transaction fees"
- "**Time-space tradeoff**: We sacrifice no space (O(1)) and gain optimal time (O(n)) by being clever about what we track"
time_complexity: "O(n)"
space_complexity: "O(1)"
complexity_explanation: |
Time: Single pass through the prices array.
Space: Only two variables needed (min_price, max_profit).
time_complexity: "O(n). We traverse the list of prices exactly once."
space_complexity: "O(1). We only use two variables (`min_price` and `max_profit`), regardless of the input size."
solutions:
- approach_name: Single Pass (Optimal)
- approach_name: Single Pass (Greedy)
is_optimal: true
code: |
def max_profit(prices: list[int]) -> int:
# Start with infinity so first price becomes minimum
min_price = float('inf')
# Start with 0 since that's our answer if no profit possible
max_profit = 0
for price in prices:
# Found a new lowest price? Update our buy point
if price < min_price:
min_price = price
elif price - min_price > max_profit:
max_profit = price - min_price
# Calculate profit if we sold today at this price
else:
current_profit = price - min_price
# Keep track of the best profit we've seen
if current_profit > max_profit:
max_profit = current_profit
return max_profit
explanation: |
Track minimum price seen so far and maximum profit achievable.
Update both as we iterate through prices.
**Time Complexity:** O(n) — Single pass through the array.
**Space Complexity:** O(1) — Only two variables used.
We iterate once, updating our minimum buy price and maximum achievable profit as we go. The greedy choice at each step (buy at lowest seen, sell at current if profitable) guarantees the optimal solution.
- approach_name: Brute Force
is_optimal: false
code: |
def max_profit(prices: list[int]) -> int:
max_profit = 0
n = len(prices)
# Try every possible buy day
for i in range(n):
# Try every possible sell day after buy day
for j in range(i + 1, n):
profit = prices[j] - prices[i]
max_profit = max(max_profit, profit)
return max_profit
explanation: |
**Time Complexity:** O(n²) — Nested loops comparing all pairs.
**Space Complexity:** O(1) — Only tracking max_profit.
This approach checks every valid (buy, sell) pair. While correct, it's too slow for large inputs and will result in TLE on LeetCode. Included here to illustrate why the greedy approach is necessary.

View File

@@ -9,82 +9,158 @@ categories:
patterns:
- dynamic-programming
description: |
You are climbing a staircase. It takes n steps to reach the top.
function_signature: "def climb_stairs(n: int) -> int:"
Each time you can either climb 1 or 2 steps. In how many distinct ways can you climb to the top?
test_cases:
visible:
- input: { n: 2 }
expected: 2
- input: { n: 3 }
expected: 3
hidden:
- input: { n: 1 }
expected: 1
- input: { n: 4 }
expected: 5
- input: { n: 5 }
expected: 8
- input: { n: 10 }
expected: 89
description: |
You are climbing a staircase. It takes `n` steps to reach the top.
Each time you can either climb **1** or **2** steps. In how many distinct ways can you climb to the top?
constraints: |
- 1 <= n <= 45
- `1 <= n <= 45`
examples:
- input: "n = 2"
output: "2"
explanation: "Two ways: (1+1) or (2)"
explanation: "There are two ways: (1 step + 1 step) or (2 steps)."
- input: "n = 3"
output: "3"
explanation: "Three ways: (1+1+1), (1+2), (2+1)"
explanation: "There are three ways: (1+1+1), (1+2), or (2+1)."
explanation:
approach: |
1. Recognize this follows the Fibonacci pattern
2. ways(n) = ways(n-1) + ways(n-2)
3. From step n-1, we can take 1 step to reach n
4. From step n-2, we can take 2 steps to reach n
5. Base cases: ways(1) = 1, ways(2) = 2
intuition: |
At any step, you either got there by taking 1 step from the previous position
or 2 steps from two positions back. This gives us the recurrence relation.
Imagine standing on a step and thinking backwards: "How did I get here?" You either took 1 step from the previous step, or you took 2 steps from two steps back. There's no other way!
This is essentially the Fibonacci sequence! The number of ways to reach step n
equals the sum of ways to reach steps n-1 and n-2.
Think of it like this: the number of ways to reach step `n` is the **sum** of:
- Ways to reach step `n-1` (then take 1 step)
- Ways to reach step `n-2` (then take 2 steps)
This gives us the recurrence: `ways(n) = ways(n-1) + ways(n-2)`
If this looks familiar, it should — this is the **Fibonacci sequence**! The number of ways to climb n stairs equals the n<sup>th</sup> Fibonacci number (with slightly shifted base cases).
The key insight is recognising **overlapping subproblems**: calculating `ways(5)` requires `ways(4)` and `ways(3)`, but `ways(4)` also requires `ways(3)`. Without caching, we'd recalculate the same values exponentially many times.
approach: |
We solve this using **Space-Optimised Dynamic Programming**:
**Step 1: Identify base cases**
- `ways(1) = 1`: Only one way to climb 1 step (take 1 step)
- `ways(2) = 2`: Two ways to climb 2 steps (1+1 or 2)
- If `n <= 2`, return `n` directly
&nbsp;
**Step 2: Initialise tracking variables**
- `prev1 = 2`: Ways to reach step 2 (the "previous" step)
- `prev2 = 1`: Ways to reach step 1 (the "step before previous")
- We only need these two values — not the entire history!
&nbsp;
**Step 3: Iterate from step 3 to n**
- For each step `i`, calculate `current = prev1 + prev2`
- Shift the window: `prev2 = prev1`, `prev1 = current`
- This simulates "sliding" up the staircase, only remembering the last two values
&nbsp;
**Step 4: Return the result**
- After the loop, `prev1` contains `ways(n)`
&nbsp;
This bottom-up approach avoids recursion overhead and uses only O(1) space by discarding values we no longer need.
common_pitfalls:
- title: Using recursion without memoization
- title: Naive Recursion Without Memoisation
description: |
Naive recursion recalculates the same subproblems repeatedly, leading to
exponential time complexity. Either use memoization or iterative DP.
wrong_approach: "return climb(n-1) + climb(n-2) without caching"
correct_approach: "Use bottom-up DP or memoize recursive calls"
The most intuitive approach — `return climb(n-1) + climb(n-2)` — has **exponential time complexity O(2^n)**.
- title: Off-by-one in base cases
Without caching, we recalculate the same subproblems repeatedly. For example, `climb(5)` calls `climb(4)` and `climb(3)`. Then `climb(4)` calls `climb(3)` again!
The fix is either memoisation (top-down caching) or iterative DP (bottom-up).
wrong_approach: "return climb(n-1) + climb(n-2) without caching"
correct_approach: "Use @lru_cache decorator or iterative DP"
- title: Off-by-One in Base Cases
description: |
Carefully define base cases. There's 1 way to stay at ground (step 0),
1 way to reach step 1, and 2 ways to reach step 2.
Getting the base cases wrong leads to incorrect answers for all inputs. Remember:
- `ways(0) = 1` (one way to stay at ground: do nothing)
- `ways(1) = 1` (one way: take 1 step)
- `ways(2) = 2` (two ways: 1+1 or 2)
Some solutions use `ways(0) = 1, ways(1) = 1` and start iteration from step 2. Others use `ways(1) = 1, ways(2) = 2` and start from step 3. Either works if consistent.
wrong_approach: "Inconsistent base case definitions"
correct_approach: "Define base cases clearly and iterate from the correct starting point"
- title: Stack Overflow on Large Inputs
description: |
Even with memoisation, recursive solutions can hit stack limits for large `n`. With `n <= 45`, this is usually fine in Python, but iterative solutions are safer and more efficient.
The iterative approach uses O(1) space and has no recursion overhead.
wrong_approach: "Deep recursive calls without tail-call optimisation"
correct_approach: "Use iterative bottom-up DP for guaranteed O(1) space"
key_takeaways:
- Many counting problems follow Fibonacci-like patterns
- Convert recursion to iteration for O(1) space
- Bottom-up DP avoids stack overflow for large inputs
- Recognize overlapping subproblems as a DP signal
- "**Fibonacci pattern recognition**: Many counting problems follow this recurrence — recognise it instantly"
- "**Space optimisation**: When you only need the last k values, don't store the entire DP array"
- "**Bottom-up vs top-down**: Iterative DP avoids stack overhead and is often cleaner"
- "**Foundation for harder DP**: This problem teaches the core DP concepts — optimal substructure and overlapping subproblems"
time_complexity: "O(n)"
space_complexity: "O(1)"
complexity_explanation: |
Time: We compute n states, each in O(1).
Space: Only track two previous values (space-optimized DP).
time_complexity: "O(n). We compute each state from 3 to n exactly once, with O(1) work per state."
space_complexity: "O(1). We only store two variables (`prev1` and `prev2`), regardless of input size."
solutions:
- approach_name: Space-Optimized DP (Optimal)
- approach_name: Space-Optimised DP
is_optimal: true
code: |
def climb_stairs(n: int) -> int:
# Base cases: 1 way for step 1, 2 ways for step 2
if n <= 2:
return n
prev1, prev2 = 2, 1
# Track only the two previous values
prev1 = 2 # ways(2)
prev2 = 1 # ways(1)
# Build up from step 3 to n
for i in range(3, n + 1):
# Current = sum of two previous (Fibonacci recurrence)
current = prev1 + prev2
# Slide the window forward
prev2 = prev1
prev1 = current
return prev1
explanation: |
Track only the two previous values since that's all we need.
Equivalent to computing the nth Fibonacci number.
**Time Complexity:** O(n) — Single pass from 3 to n.
- approach_name: Recursive with Memoization
**Space Complexity:** O(1) — Only two variables needed.
We recognise this as the Fibonacci sequence and compute it iteratively. By only tracking the two most recent values, we achieve optimal space complexity while avoiding recursion overhead.
- approach_name: Recursive with Memoisation
is_optimal: false
code: |
from functools import lru_cache
@@ -92,11 +168,16 @@ solutions:
def climb_stairs(n: int) -> int:
@lru_cache(maxsize=None)
def dp(step: int) -> int:
# Base cases
if step <= 2:
return step
# Recurrence: ways to reach step = sum of two previous
return dp(step - 1) + dp(step - 2)
return dp(n)
explanation: |
Top-down approach with memoization.
Uses O(n) space for the cache and recursion stack.
**Time Complexity:** O(n) — Each subproblem computed once due to caching.
**Space Complexity:** O(n) — Cache stores n values, plus recursion stack depth.
Top-down approach with memoisation. The `@lru_cache` decorator automatically caches results, preventing redundant calculations. While elegant, it uses more space than the iterative solution.

View File

@@ -9,18 +9,36 @@ categories:
patterns:
- two-pointers
description: |
Given an array of integers `nums` and an integer `target`, return indices of the two numbers such that they add up to `target`.
function_signature: "def two_sum(nums: list[int], target: int) -> list[int]:"
You may assume that each input would have exactly one solution, and you may not use the same element twice.
test_cases:
visible:
- input: { nums: [2, 7, 11, 15], target: 9 }
expected: [0, 1]
- input: { nums: [3, 2, 4], target: 6 }
expected: [1, 2]
- input: { nums: [3, 3], target: 6 }
expected: [0, 1]
hidden:
- input: { nums: [1, 5, 3, 7, 2], target: 10 }
expected: [2, 3]
- input: { nums: [-1, -2, -3, -4, -5], target: -8 }
expected: [2, 4]
- input: { nums: [0, 4, 3, 0], target: 0 }
expected: [0, 3]
description: |
Given an array of integers `nums` and an integer `target`, return *indices of the two numbers such that they add up to `target`*.
You may assume that each input would have **exactly one solution**, and you may not use the same element twice.
You can return the answer in any order.
constraints: |
- 2 <= nums.length <= 10^4
- -10^9 <= nums[i] <= 10^9
- -10^9 <= target <= 10^9
- Only one valid answer exists.
- `2 <= nums.length <= 10^4`
- `-10^9 <= nums[i] <= 10^9`
- `-10^9 <= target <= 10^9`
- Only one valid answer exists
examples:
- input: "nums = [2,7,11,15], target = 9"
@@ -34,84 +52,128 @@ examples:
explanation: "Because nums[0] + nums[1] == 6, we return [0, 1]."
explanation:
approach: |
1. Create a hash map to store each number and its index as we iterate
2. For each number, calculate its complement (target - current number)
3. Check if the complement exists in the hash map
4. If found, return the current index and the complement's index
5. If not found, add the current number and its index to the hash map
6. Continue until a pair is found
intuition: |
The brute force approach would check every pair of numbers, resulting in O(n²) time.
Instead, we can use a hash map to achieve O(n) time by trading space for speed.
Imagine you're at a party with numbered name tags, and you need to find two people whose numbers add up to a target sum. The brute force approach would be to ask every person to compare their number with every other person — exhausting and slow!
The key insight is that for each number x, we're looking for (target - x). Rather than
scanning the entire array each time, we store seen numbers in a hash map for O(1) lookup.
Think of it like this: as you meet each person, you write down their number and where you met them in a notebook (hash map). When you meet someone new, you quickly calculate what number you *need* to find (`target - current number`) and check your notebook. If it's there, you've found your pair instantly!
We build the hash map as we go, which elegantly handles the constraint of not using
the same element twice — when we check for a complement, it can only be a previously
seen element.
The key insight is that for any number `x`, its **complement** is `target - x`. Instead of scanning the entire array each time looking for the complement, we store seen numbers in a hash map for O(1) lookup. This transforms our search from O(n) per element to O(1) per element.
Building the hash map as we iterate also elegantly handles the constraint of not using the same element twice — when we check for a complement, it can only be a *previously seen* element, never the current one.
approach: |
We solve this using a **One-Pass Hash Map Approach**:
**Step 1: Initialise a hash map**
- Create an empty dictionary `seen` to store numbers and their indices
- This will let us look up any previously encountered number in O(1) time
&nbsp;
**Step 2: Iterate through the array**
- For each number at index `i`, calculate its complement: `complement = target - num`
- Check if `complement` exists in `seen`
- If found, we have our answer: return `[seen[complement], i]`
- If not found, store the current number and its index: `seen[num] = i`
&nbsp;
**Step 3: Return the result**
- The problem guarantees exactly one solution, so we'll always find a match during iteration
- If somehow no match exists, return an empty list as a fallback
&nbsp;
This approach works because by checking *before* adding to the map, we ensure we never match an element with itself, and we find the earliest valid pair.
common_pitfalls:
- title: Using the same element twice
- title: The Brute Force Trap
description: |
Checking if complement exists before adding current element to the map prevents
using the same index twice. If you add first then check, you might match an
element with itself.
A common first instinct is to use nested loops to check every pair:
- Outer loop `i` from `0` to `n-1`
- Inner loop `j` from `i+1` to `n-1`
This results in **O(n²) time complexity**. While it works, with `nums.length <= 10^4`, you could have up to 100 million comparisons. The hash map approach reduces this to a single pass.
wrong_approach: "Nested loops comparing all pairs"
correct_approach: "Single pass with hash map for O(1) lookups"
- title: Using the Same Element Twice
description: |
If you add a number to the hash map *before* checking for its complement, you might match an element with itself.
For example, with `nums = [3, 2, 4]` and `target = 6`: if you add `3` to the map first, then check if `6 - 3 = 3` exists, you'd incorrectly find it!
By checking the complement *before* adding the current number, we guarantee we're only matching with previously seen elements.
wrong_approach: "Adding to map before checking complement"
correct_approach: "Check complement first, then add to map"
- title: Returning values instead of indices
- title: Returning Values Instead of Indices
description: |
The problem asks for indices, not the actual values. Make sure you store and
return the indices from the hash map.
The problem asks for **indices**, not the actual values. A common mistake is returning `[num, complement]` instead of their positions.
Always read the problem carefully — "return indices" means storing and returning index values from the hash map.
wrong_approach: "return [num, complement]"
correct_approach: "return [seen[complement], i]"
- title: Forgetting duplicate values
description: |
When there are duplicate values (e.g., [3,3] with target 6), the algorithm
still works because we check for complement before adding to the map.
key_takeaways:
- Hash maps trade space for time, turning O(n) lookups into O(1)
- Building data structures incrementally can prevent edge cases
- Always clarify whether to return indices or values
- This pattern appears in many "find pair" problems
- "**Hash map for complement lookup**: Trading O(n) space for O(1) lookup time is a foundational optimisation pattern"
- "**Check before insert**: Building data structures incrementally while checking prevents edge cases like self-matching"
- "**The Two Sum pattern**: This exact technique (hash map + complement) appears in many 'find pair' problems like 3Sum, 4Sum, and pair-with-given-sum variants"
- "**Indices vs values**: Always verify what the problem asks you to return — this catches many wrong answers"
time_complexity: "O(n)"
space_complexity: "O(n)"
complexity_explanation: |
Time: We iterate through the array once, and each hash map operation is O(1) average.
Space: In the worst case, we store all n elements in the hash map before finding a match.
time_complexity: "O(n). We traverse the array once, and each hash map lookup/insert is O(1) on average."
space_complexity: "O(n). In the worst case, we store all n elements in the hash map before finding a match (e.g., when the solution is the last two elements)."
solutions:
- approach_name: Hash Map (Optimal)
- approach_name: One-Pass Hash Map
is_optimal: true
code: |
def two_sum(nums: list[int], target: int) -> list[int]:
# Hash map to store number -> index mappings
seen = {}
for i, num in enumerate(nums):
# Calculate what number we need to find
complement = target - num
# Check if we've seen the complement before
if complement in seen:
# Found it! Return both indices
return [seen[complement], i]
# Haven't found a match yet, store current number
seen[num] = i
return [] # No solution found
# Problem guarantees a solution, but return empty as fallback
return []
explanation: |
Single pass through the array, storing each number's index.
For each number, check if its complement exists in the map.
**Time Complexity:** O(n) — Single pass through the array with O(1) hash map operations.
**Space Complexity:** O(n) — Hash map stores up to n-1 elements before finding the match.
We iterate once, storing each number's index in a hash map. For each number, we check if its complement exists in the map. By checking before inserting, we ensure we never match an element with itself.
- approach_name: Brute Force
is_optimal: false
code: |
def two_sum(nums: list[int], target: int) -> list[int]:
n = len(nums)
# Try every possible pair of indices
for i in range(n):
for j in range(i + 1, n):
# Check if this pair sums to target
if nums[i] + nums[j] == target:
return [i, j]
# No solution found
return []
explanation: |
Check every pair of numbers. Simple but inefficient for large inputs.
Time: O(n²), Space: O(1).
**Time Complexity:** O(n²) — Nested loops check all possible pairs.
**Space Complexity:** O(1) — No additional data structures needed.
This approach checks every valid pair of indices. While correct and simple to understand, it's inefficient for large inputs. Included here to illustrate the progression from brute force to optimal solution.

View File

@@ -9,17 +9,40 @@ categories:
patterns:
- monotonic-stack
function_signature: "def is_valid(s: str) -> bool:"
test_cases:
visible:
- input: { s: "()" }
expected: true
- input: { s: "()[]{}" }
expected: true
- input: { s: "(]" }
expected: false
hidden:
- input: { s: "([)]" }
expected: false
- input: { s: "{[]}" }
expected: true
- input: { s: "((()))" }
expected: true
- input: { s: "(" }
expected: false
- input: { s: ")(" }
expected: false
description: |
Given a string `s` containing just the characters `'('`, `')'`, `'{'`, `'}'`, `'['` and `']'`, determine if the input string is valid.
An input string is valid if:
1. Open brackets must be closed by the same type of brackets.
2. Open brackets must be closed in the correct order.
3. Every close bracket has a corresponding open bracket of the same type.
constraints: |
- 1 <= s.length <= 10^4
- s consists of parentheses only '()[]{}'
- `1 <= s.length <= 10^4`
- `s` consists of parentheses only `'()[]{}'`
examples:
- input: 's = "()"'
@@ -33,102 +56,139 @@ examples:
explanation: "Mismatched bracket types."
- input: 's = "([)]"'
output: "false"
explanation: "Incorrect nesting order."
explanation: "Incorrect nesting order — the inner bracket must close before the outer one."
explanation:
approach: |
1. Create a mapping of closing brackets to their opening counterparts
2. Initialize an empty stack to track opening brackets
3. Iterate through each character in the string:
- If it's an opening bracket, push it onto the stack
- If it's a closing bracket, check if the stack is empty (invalid) or if the top
of the stack matches the corresponding opening bracket
4. After processing all characters, the stack should be empty for a valid string
intuition: |
The key insight is that brackets must be closed in LIFO (Last-In-First-Out) order.
The most recently opened bracket must be closed first, which is exactly what a stack does.
Imagine you're reading a book with nested parenthetical comments. Each time you see an opening bracket, you mentally "start" a new thought. When you encounter a closing bracket, it *must* complete the most recent unclosed thought — not some earlier one.
When we encounter a closing bracket, the most recent unclosed opening bracket (top of stack)
must match it. If they don't match, or if there's no opening bracket to match, the string
is invalid.
Think of it like stacking plates: you can only remove the plate on top (last in, first out). The most recently opened bracket must be closed first before you can close any outer brackets.
Think of it like nested function calls — the innermost function must return before the
outer one can.
This **LIFO (Last-In-First-Out)** behavior is exactly what a stack does! When we see an opening bracket, we push it onto the stack. When we see a closing bracket, we check if it matches the bracket on top of the stack (the most recent unclosed one).
The key insight is that valid bracket sequences have a **mirror-like property**: if you trace through a valid string, every closing bracket you encounter should match the most recent unmatched opening bracket.
approach: |
We solve this using a **Stack-Based Matching Approach**:
**Step 1: Create a mapping and initialise the stack**
- Map each closing bracket to its corresponding opening bracket: `{')': '(', '}': '{', ']': '['}`
- Initialise an empty stack to track unmatched opening brackets
&nbsp;
**Step 2: Iterate through each character**
- If it's an **opening bracket** (`(`, `{`, `[`): push it onto the stack
- If it's a **closing bracket** (`)`, `}`, `]`):
- Check if the stack is empty — if so, there's no opening bracket to match, return `False`
- Check if the top of the stack matches the expected opening bracket — if not, return `False`
- If it matches, pop the stack (this opening bracket is now matched)
&nbsp;
**Step 3: Final validation**
- After processing all characters, check if the stack is empty
- An empty stack means every opening bracket found its match
- A non-empty stack means there are unmatched opening brackets
&nbsp;
This works because the stack maintains the order of unmatched opening brackets, and we always match against the most recent one.
common_pitfalls:
- title: Forgetting to check empty stack
- title: Forgetting to Check for Empty Stack
description: |
When encountering a closing bracket, you must first check if the stack is empty.
If it is, there's no matching opening bracket.
wrong_approach: "Directly checking stack[-1] without empty check"
correct_approach: "Check if stack is empty before accessing stack[-1]"
When you encounter a closing bracket, you must first check if the stack is empty. Attempting to access `stack[-1]` (or `stack.pop()`) on an empty stack causes an error in most languages.
- title: Not checking if stack is empty at the end
For example, with input `")"`, there's no opening bracket to match. The stack is empty, so we should return `False` immediately.
wrong_approach: "Directly checking stack[-1] without empty check"
correct_approach: "if not stack or stack[-1] != expected: return False"
- title: Not Checking Stack at the End
description: |
After processing all characters, leftover opening brackets in the stack mean
they were never closed. Return stack is empty, not just True.
Processing all characters successfully doesn't mean the string is valid! Consider `"((("` — no errors occur during iteration, but we have unmatched opening brackets.
The string is only valid if the stack is **completely empty** after processing all characters. Returning `True` immediately after the loop is incorrect.
wrong_approach: "return True after the loop"
correct_approach: "return len(stack) == 0"
- title: Confusing bracket mapping direction
- title: Mapping Brackets in the Wrong Direction
description: |
Map closing brackets to opening brackets (not vice versa) because we encounter
closing brackets when we need to check for a match.
Map **closing brackets to opening brackets**, not the other way around. We encounter closing brackets when we need to check for a match, so we need to look up what opening bracket it should match.
Using the wrong direction requires extra logic to reverse the lookup when checking matches.
wrong_approach: "mapping = {'(': ')', '{': '}'}"
correct_approach: "mapping = {')': '(', '}': '{'}"
key_takeaways:
- Stacks are ideal for matching nested structures
- LIFO order matches the nesting requirement of brackets
- Always check edge cases (empty string, only opening, only closing)
- This pattern extends to validating HTML tags, code blocks, etc.
- "**Stack for nested structures**: Any problem involving matching or nesting (brackets, tags, function calls) likely needs a stack"
- "**LIFO matches nesting order**: The most recently opened element must close first — this is the defining insight"
- "**Two-part validation**: Check matches during iteration AND verify empty stack at the end"
- "**Pattern recognition**: This technique extends to validating HTML tags, code blocks, mathematical expressions, and any nested structure"
time_complexity: "O(n)"
space_complexity: "O(n)"
complexity_explanation: |
Time: We process each character exactly once.
Space: In the worst case (all opening brackets), the stack holds n/2 elements.
time_complexity: "O(n). We process each character exactly once, and each stack operation (push/pop/peek) is O(1)."
space_complexity: "O(n). In the worst case (all opening brackets like `(((((`), the stack holds n/2 or n elements."
solutions:
- approach_name: Stack (Optimal)
- approach_name: Stack
is_optimal: true
code: |
def is_valid(s: str) -> bool:
stack = []
# Map closing brackets to their opening counterparts
mapping = {')': '(', '}': '{', ']': '['}
# Stack to track unmatched opening brackets
stack = []
for char in s:
if char in mapping:
# Closing bracket
# It's a closing bracket — check for match
if not stack or stack[-1] != mapping[char]:
# Stack empty or top doesn't match
return False
# Match found, remove the opening bracket
stack.pop()
else:
# Opening bracket
# It's an opening bracket — add to stack
stack.append(char)
# Valid only if all brackets were matched
return len(stack) == 0
explanation: |
Use a stack to track opening brackets. For each closing bracket,
verify it matches the most recent opening bracket.
**Time Complexity:** O(n) — Single pass through the string with O(1) stack operations.
- approach_name: Stack with Early Return
**Space Complexity:** O(n) — Stack can hold up to n/2 opening brackets in the worst case.
We use a stack to track unmatched opening brackets. For each closing bracket, we verify it matches the most recent opening bracket (top of stack). After processing all characters, an empty stack confirms all brackets were properly matched.
- approach_name: Stack with Early Length Check
is_optimal: true
code: |
def is_valid(s: str) -> bool:
# Quick check: odd length can never be valid
# Quick optimisation: odd length can never be valid
if len(s) % 2 != 0:
return False
stack = []
# Map opening brackets to expected closing brackets
pairs = {'(': ')', '{': '}', '[': ']'}
stack = []
for char in s:
if char in pairs:
# Opening bracket — push expected closing bracket
stack.append(pairs[char])
elif not stack or stack.pop() != char:
# Closing bracket — must match what we expect
return False
# All brackets must be matched
return not stack
explanation: |
Optimization: push the expected closing bracket instead of the opening one.
This simplifies the comparison when we encounter a closing bracket.
**Time Complexity:** O(n) — Single pass with O(1) operations.
**Space Complexity:** O(n) — Stack storage.
This variation pushes the *expected closing bracket* instead of the opening one. When we encounter a closing bracket, we simply compare it directly with `stack.pop()`. The early length check provides a quick exit for obviously invalid inputs.