
LeetCode: 1D Dynamic Programming
1D Dynamic Programming Intro
- BFS To DP
- What is 1D Dynamic Programming
- Recursive Memoization: Improving Recursion
- Iterative: Tabulation and Variables
- (0 to i or i to N) Recursive Top Down vs (0 to i) Iterative Bottom Up
- DP Array Size / Padding / Indexing / Shifting
- 1D Dynamic Programming IRL
- 1D Dynamic Programming Application: DFS with Caching (Padded N+1) Top Down with Memoization
- 1D Dynamic Programming Application: DFS with Caching (Direct N) Top Down with Memoization
- 1D Dynamic Programming Application: Iterative Tabulation (Padded N+1) Bottom Up
- 1D Dynamic Programming Application: Iterative Tabulation (Direct N) Bottom Up
- 1D Dynamic Programming Application: Optimal Iterative (Padded N+1) Rolling State Variables Bottom Up
- 1D Dynamic Programming Application: Optimal Iterative (Direct N) Rolling State Variables Bottom Up
- 1D Dynamic Programming Application: Opposite Ends Iterative (Direct N) Rolling State Variables Bottom Up
- 1D Dynamic Programming Application: Sliding Window Iterative (Direct N) Rolling State Variables Bottom Up
- 1D Dynamic Programming Application: BFS Visited Memo Level Order
70. Climbing Stairs ::3:: - Easy
- Intro
- Abstraction
- Pseudocode
- Solution 1: [BFS] BFS Path Count Over Implicit Step Graph - 1D Dynamic Programming/Optimal Iterative (Padded N+1) Rolling State Variables Bottom Up
- Solution 2: [DP] 0 to i Recursive with Memoization Top Down - 1D Dynamic Programming/DFS with Caching (Padded N+1) Top Down with Memoization
- Solution 3: [DP] 0 to i Iterative Bottom Up Variables - 1D Dynamic Programming/Optimal Iterative (Padded N+1) Rolling State Variables Bottom Up
746. Min Cost Climbing Stairs ::3:: - Easy
- Intro
- Abstraction
- Pseudocode
- Solution 1: [BFS] BFS Cost Relaxation Over Implicit Step Graph - 1D Dynamic Programming/DFS with Caching (Direct N) Top Down with Memoization
- Solution 2: [DP] i to N Recursive with Explicit Memoization Top Down - 1D Dynamic Programming/DFS with Caching (Direct N) Top Down with Memoization
- Solution 3: [DP] 0 to i Iterative Bottom Up Variables - 1D Dynamic Programming/Optimal Iterative (Direct N) Rolling State Variables Bottom Up
1137. Nth Tribonacci Number ::3:: - Easy
- Intro
- Abstraction
- Pseudocode
- Solution 1: [BFS] BFS Value Propagation Over Implicit Index - 1D Dynamic Programming/DFS with Caching (Direct N) Top Down with Memoization
- Solution 2: [DP] i to N Recursive with Explicit Memoization Top Down - 1D Dynamic Programming/DFS with Caching (Direct N) Top Down with Memoization
- Solution 3 [DP] 0 to i Iterative Bottom Up Variables - 1D Dynamic Programming/Optimal Iterative (Direct N) Rolling State Variables Bottom Up
4050. Minimum Days to Score Exactly N Points ::3:: - Medium
- Intro
- Abstraction
- Pseudocode
- Solution 1: [BFS] BFS Over Implicit Remaining Sum Graph [Time Out] - Graph/BFS Level Order Shortest Path
- Solution 2: [DP] [DFS] Top Down Recursive With Memoization [Time Out] - 1D Dynamic Programming/DFS With Caching Top Down
- Solution 3: [DP] Bottom Up Iterative Tabulation - 1D Dynamic Programming/Iterative Tabulation Bottom Up
279. Perfect Squares ::3:: - Medium
- Intro
- Abstraction
- Pseudocode
- Solution 1: [BFS] BFS Over Implicit Remaining Sum Graph - Graph/BFS Level Order Shortest Path
- Solution 2: [DP] [DFS] Top Down Recursive With Memoization - 1D Dynamic Programming/DFS With Caching Top Down
- Solution 3: [DP] Bottom Up Iterative Tabulation - 1D Dynamic Programming/Iterative Tabulation Bottom Up
198. House Robber ::3:: - Medium
- Intro
- Abstraction
- Pseudocode
- Solution 1: [BFS] BFS Max Candidate Propagation Over Implicit Graph - 1D Dynamic Programming/DFS with Caching (Direct N) Top Down with Memoization
- Solution 2: [DP] i to N Recursive with Explicit Memoization Top Down - 1D Dynamic Programming/DFS with Caching (Direct N) Top Down with Memoization
- Solution 3: [DP] 0 to i Iterative Bottom Up Variables - 1D Dynamic Programming/Optimal Iterative (Direct N) Rolling State Variables Bottom Up
213. House Robber II ::3:: - Medium
- Intro
- Abstraction
- Pseudocode
- Solution 1: [BFS] BFS Max Candidate Propagation Over Two Linear Sub Ranges - 1D Dynamic Programming/DFS with Caching (Direct N) Top Down with Memoization
- Solution 1: [DP] i to N Recursive with Memoization Top Down - 1D Dynamic Programming/DFS with Caching (Direct N) Top Down with Memoization
- Solution 2: [DP] 0 to i Iterative Bottom Up Variables - 1D Dynamic Programming/Optimal Iterative (Direct N) Rolling State Variables Bottom Up
647. Palindromic Substrings ::3:: - Medium
- Intro
- Abstraction
- Pseudocode
- Solution 1: [BFS] BFS Multi Source Expansion From All Centers - 1D Dynamic Programming/Linear Property Tracking
- Solution 2: [DP] Two Pointers Expand Around Center - 1D Dynamic Programming/Linear Property Tracking
- Solution 3: [DP] Dynamic Programming - 1D Dynamic Programming/Linear Property Tracking
91. Decode Ways ::3:: - Medium
- Intro
- Abstraction
- Pseudocode
- Solution 1: [BFS] BFS Forward Propagation Over Implicit Decoding - 1D Dynamic Programming/DFS with Caching (Direct N) Top Down with Memoization
- Solution 2: [DP] i to N Recursive with Memoization Top Down - 1D Dynamic Programming/DFS with Caching (Direct N) Top Down with Memoization
- Solution 3: [DP] 0 to i Iterative Bottom Up Variables - 1D Dynamic Programming/Optimal Iterative (Direct N) Rolling State Variables Bottom Up
152. Maximum Product Subarray ::2:: - Medium
- Intro
- Abstraction
- Pseudocode
- Solution 1: [DP] BFS Forward Propagation Of Max Min Product Pairs - 1D Dynamic Programming/DFS with Caching (Direct N) Top Down with Memoization
- Solution 2: [DP] Modified Kadane i to N Recursive with Memoization Top Down - 1D Dynamic Programming/DFS with Caching (Direct N) Top Down with Memoization
- Solution 3: [DP] Modified Kadane 0 to i Iterative Bottom Up Variables - 1D Dynamic Programming/Optimal Iterative (Direct N) Rolling State Variables Bottom Up
139. Word Break ::4:: - Medium
- Intro
- Abstraction
- Pseudocode
- Solution 1: [BFS] BFS Over Implicit Segmentation Point Graph - 1D Dynamic Programming/DFS with Caching (Direct N) Top Down with Memoization
- Solution 2: [DP] i to N Recursive with Memoization Top Down - 1D Dynamic Programming/DFS with Caching (Direct N) Top Down with Memoization
- Solution 3: [DP] 0 to i Iterative Bottom Up Rolling Variables Sliding Window - 1D Dynamic Programming/Sequential Segment Choice Validation
300. Longest Increasing Subsequence ::3:: - Medium
- Intro
- Abstraction
- Pseudocode
- Solution 1: [BFS] BFS Longest Path Relaxation Over Implicit Graph - 1D Dynamic Programming/Subsequence Optimization Constrained
- Solution 2: Dynamic Programming - 1D Dynamic Programming/Subsequence Optimization Constrained
- Solution 3: Binary Search - 1D Dynamic Programming/Subsequence Optimization Constrained
416. Partition Equal Subset Sum ::2:: - Medium
- Intro
- Abstraction
- Pseudocode
- Solution 1: [BFS] BFS Over Implicit Reachable Sum Graph - 1D Dynamic Programming/Subset Sum Linear Choice Selection
- Solution 2: [DP] Dynamic Programming Subset Sum - 1D Dynamic Programming/Subset Sum Linear Choice Selection
- Solution 3: [DP] Bitmask Bitset DP - 1D Dynamic Programming/Subset Sum Linear Choice Selection
1D Dynamic Programming Intro
LeetCode problems solved with dynamic programming
BFS To DP
DP is just BFS when there are overlapping sub problems. Hint, see solutions for 4050 Minimum Days to Score Exactly N Points
What is 1D Dynamic Programming
Dynamic Programming (DP) is a technique for solving problems that can be broken into overlapping sub problems and have optimal substructure, meaning the optimal solution can be built from optimal solutions of sub problems.
Instead of solving the same subproblem repeatedly, DP stores solutions in a table via memorization or a bottom up array, to avoid redundant work.
Recursive Memoization: Improving Recursion
Many 1D Dynamic Programming problems naturally arise from recursion. In climbing stairs problem, a naive recursive solutions explores all paths:
dfs(i) = dfs(i+1) + dfs(i+2)While this recursion works logically, it recomputes overlapping sub problems.
- dfs(i) calls dfs(i+1) and dfs(i+2).
- dfs(i+1) recomputes dfs(i+2), with its own dfs(i+1) -> dfs(i+1+1)
- dfs(i+2) is computed twice, as n grows, recursion trees grows to O(2n)
Memoization solves this by caching results of sub problems to avoid recomputation.
Iterative: Tabulation and Variables
Dynamic Programming can also be implemented iteratively via bottom up by computing solutions from small sub problems to larger ones.
- Tabulation Full Array
Here we store the solution of ever sub problem in an array dp[]. Each state depends on previous states, and we compute all of them sequentially from 0 -> N. Leads to an array of O(n)
- Optimal
Often, each state depends on a fixed number of previous states, usually the last 1 or 2. Here, we replace the tabulation array with variables, reducing the space form O(n) to O(1).
(0 to i or i to N) Recursive Top Down vs (0 to i) Iterative Bottom Up
We have two ways to solve dynamic programming problems. Recursive and iterative, and each have their own strategy for building up problems.
- Top Down: Recursive + Memoization We start from the target and recursively break up the problem into sub problems until we reach the base case.
Ex: fib(n) = fib(n-1) + fib(n-2)
by calling fib(n-1) and fib(n-2) recursively, retrieving the value and getting fib(n)
Here, we run into overlapping sub problems which we solve with memoization to avoid recomputation.
- Bottom Up: Iterative Array and Variables We start from the bottom and iteratively build solutions up for larger sub problems until we reach the nth case.
Ex: solve fib(1), fib(2), fib(3) .... until we get ... fib(n)
by iterating from 3 to n, with formula fib(n) = fib(n-1) + fib(n-2)
Visual Comparison:
Top-Down: n
/ \
n-1 n-2
/ \ ...
...
computed recursively
Bottom-Up: 0 1 2 3 ... n
computed iterativelyDP Array Size / Padding / Indexing / Shifting
The choice of a DP array size of (N) or (N+1) depends on whether we are padding the DP array and thus what the DP array will represent.
For a naive approach, we could just do the mental trick: If we do size (N) we need dp[n-1] as final answer. If we do size (N+1) we need dp[n] as final answer.
- Prefix View (N+1) / 1 indexed View
Here, we 'pad' the DP array with one extra state that does not correspond to the original array. This extra state is usually the empty case or base case.
This leads to a 1 indexed view where arr[0] corresponds to dp[1].
dp[i] then represents the answer for the first i elements: steps[0..i-1]
dp[0] = answer for [] elements (empty case) dp[n] = answer for first [0...n-1] elements (all of them)
dp size then must be dp[n+1]
- Element View (N) / 0 Indexed View
Here, we do not 'pad' the DP array and instead do direct matching with the original array.
This leads to a 0 indexed view where arr[i] corresponds to dp[i].
dp[i] then represents the answer for element i: houses[i]
dp[0] = answer for 1 element (first element) dp[n-1] = answer for last element
dp size then must be dp[n]
House Robber: (N) + (N+1)
(N) Natural Here, dp[i] represents maximum money that can be robbed from houses 0..i
def rob(self, nums: List[int]) -> int:
n = len(nums)
# Edge case: only 1 house
# Without this, accessing nums[1] would cause IndexError
if n == 1:
return nums[0]
# dp[i] = max money robbed from houses 0..i
# Rule (2): "element i itself"
dp = [0] * n # direct:
dp[0] = nums[0] # direct: max loop up to ith house
dp[1] = max(nums[0], nums[1]) # direct: max loop up to 2nd house
for i in range(2, n):
dp[i] = max(dp[i-1], dp[i-2] + nums[i])
return dp[n-1]
(N+1) Shifted Here, dp[i] represents the max money robbed from the first i houses. This shifts the definition forward by 1 and naturally uses n+1 size.
def rob(nums: List[int]) -> int:
n = len(nums)
# dp[i] = max money robbed from the first i houses
# Rule (1): "the first i elements"
dp = [0] * (n+1) # padding:
dp[0] = 0 # padding: (empty case, max loot from no houses)
dp[1] = nums[0]
for i in range(2, n+1):
dp[i] = max(dp[i-1], dp[i-2] + nums[i-1])
return dp[n]Climbing stairs: (N+1) + (N)
(N+1) Natural Here, dp[i] represents the number of ways to climb to step i Since you need to compute dp[n], you need n+1 states.
def climbStairs(n: int) -> int:
# dp[i] = ways to reach step i
# Rule (1): "the first i elements"
dp = [0] * (n+1) # padding:
dp[0] = 1 # padding: (ground case, 1 way: do nothing)
dp[1] = 1
for i in range(2, n+1):
dp[i] = dp[i-1] + dp[i-2]
return dp[n](N) Shifted Here, dp[i] represents the number of ways to reach steps (i+1). So the DP array is only of length n (last index n-1 = ways to reach step n).
def climbStairs(n: int) -> int:
# Edge cases:
# Without this, setting dp[1] would cause IndexError
if n == 1:
return 1
# dp[i] = ways to reach step (i+1)
# Rule (2): "element i itself"
dp = [0] * n # direct
dp[0] = 1 # direct: (ways to reach step 1)
dp[1] = 2 # direct: (ways to reach step 2)
for i in range(2, n):
dp[i] = dp[i-1] + dp[i-2]
return dp[n-1]1D Dynamic Programming IRL
Networking: Optimize packet processing sequences with limited buffers.
Resource Allocation: Task scheduling with constraints (eg, cannot pick consecutive tasks)
Finance: Maximize profit in trading tracking best profit up to day if
1D Dynamic Programming Application: DFS with Caching (Padded N+1) Top Down with Memoization
Pattern: Recursive DFS explores choices, memo stores results to avoid recomputation. Extra base 'padding' simplifies boundary checks. Use When: Problem naturally needs a “ground” or empty state. Recurrence: dfs(i) = dfs(i-1) + dfs(i-2)
Ex: Climbing Stairs (Top Down, Padded)
def climbStairs(n: int) -> int:
memo = {}
def dfs(i: int) -> int:
if i in memo:
return memo[i]
if i == 0: # padding: ground, 1 way to do nothing
return 1
if i == 1: # padded: first step
return 1
# recursive relation: sum of previous two steps
memo[i] = dfs(i-1) + dfs(i-2)
return memo[i]
return dfs(n)1D Dynamic Programming Application: DFS with Caching (Direct N) Top Down with Memoization
Pattern: Recursive DFS explores choices, memo stores results to avoid recomputation. Direct mapping aligns base cases with the first elements. Use When: Problem naturally aligns 1:1 with input array elements. Recurrence: dfs(i) = dfs(i-1) + dfs(i-2)
Ex: Climbing Stairs (Top Down, Direct)
def climbStairs(n: int) -> int:
memo = {}
def dfs(i: int) -> int:
if i in memo:
return memo[i]
if i == 1: # direct: 1st element
return 1
if i == 2: # direct: 2nd element
return 2
# recursive relation: sum of previous two steps
memo[i] = dfs(i-1) + dfs(i-2)
return memo[i]
return dfs(n)1D Dynamic Programming Application: Iterative Tabulation (Padded N+1) Bottom Up
Pattern: Iteratively fill a DP array with extra padding for base/empty case. Use When: Problem naturally needs a “ground” or empty state and empty state was not given in original array. Recurrence: dp[i] = dp[i-1] + dp[i-2] (or problem-specific relation).
Ex: Climbing Stairs (Bottom Up, Padded)
def climbStairs(n: int):
# no length == 1 check, since using pad + 1st element
# 1st element will always exist, pad is made up
dp = [0] * (n + 1) # padding:
dp[0] = 1 # padding: (ground, 1 way to do nothing),
# does not exist in original array
dp[1] = 1 # padded: first element, n=0 -> dp[1]
for i in range(2, n + 1):
dp[i] = dp[i-1] + dp[i-2]
return dp[n]1D Dynamic Programming Application: Iterative Tabulation (Direct N) Bottom Up
Pattern: Iteratively fill a DP array with direct mapping to the original array elements. Use When: Problem naturally aligns 1:1 with input array. Recurrence: dp[i] = max(dp[i-1], dp[i-2] + nums[i])
Ex: House Robber (Bottom Up, Direct)
def rob(nums):
n = len(nums)
# length == 1 check needed since using 1st + 2nd element
# 2nd element may not exist
if n == 1:
return nums[0]
dp = [0] * n # direct:
dp[0] = nums[0] # direct: 1st element
dp[1] = max(nums[0], nums[1]) # direct: 2nd element
for i in range(2, n):
dp[i] = max(dp[i-1], dp[i-2] + nums[i])
return dp[n-1]1D Dynamic Programming Application: Optimal Iterative (Padded N+1) Rolling State Variables Bottom Up
Pattern: Reduce DP array to a few variables while padding for base/ground case. Use When: Problem naturally needs a “ground” or empty state and you want O(1) space. Recurrence: curr = prev1 + prev2 -> shift forward each step.
Ex: Climbing Stairs (Optimal Bottom Up, Padded)
def climbStairs(n: int) -> int:
# no length == 1 check, since using pad + 1st element
# 1st element will always exist, pad is made up
# padded variables
prev2 = 1 # padding: ground (0 steps, 1 way to do nothing)
prev1 = 1 # first step
# no length check needed since prev1 and prev2 already cover n=1
for i in range(2, n + 1):
curr = prev1 + prev2
prev2, prev1 = prev1, curr
return prev11D Dynamic Programming Application: Optimal Iterative (Direct N) Rolling State Variables Bottom Up
Pattern: Reduce DP array to a few variables with direct mapping to the original array. Use When: Problem naturally aligns 1:1 with input array and O(1) space is desired. Recurrence: curr = prev1 + prev2 → shift forward each step.
Ex: House Robber (Optimal Bottom Up, Direct)
def rob(nums) -> int:
n = len(nums)
# length == 1 check needed since using 1st + 2nd element
# 2nd element may not exist
if n == 1:
return nums[0]
prev2 = nums[0] # direct: first element
prev1 = max(nums[0], nums[1]) # direct: second element
for i in range(2, n):
curr = max(prev1, prev2 + nums[i])
prev2, prev1 = prev1, curr
return prev11D Dynamic Programming Application: Opposite Ends Iterative (Direct N) Rolling State Variables Bottom Up
Pattern: Maintain rolling cumulative products from both ends of the array to capture subarrays affected by negative numbers. Use When: Problem involves products/subarrays where negative numbers can flip the max/min, and O(1) space is desired. Recurrence: left_prod *= nums[i], right_prod *= nums[n-1-i], reset to 1 if zero.
Ex: Maximum Product Subarray (Opposite Ends Rolling Variables)
def maxProduct(nums: List[int]) -> int:
n = len(nums)
# Direct Variables -> rolling left and right products
left_prod = right_prod = 1
res = float('-inf')
for i in range(n):
# Build From Previous -> roll from left and right ends
left_prod *= nums[i]
right_prod *= nums[n-1-i]
# Three Possibilities -> compare overall max
res = max(res, left_prod, right_prod)
# Direct Boundary -> reset rolling on zero
if left_prod == 0:
left_prod = 1
if right_prod == 0:
right_prod = 1
return res1D Dynamic Programming Application: Sliding Window Iterative (Direct N) Rolling State Variables Bottom Up
Pattern: Maintain a rolling DP window of fixed size instead of the full DP array to reduce space. Use When: Problem involves checking previous k states (e.g., word lengths in Word Break) and you want O(k) space instead of O(n). Recurrence: dp[i % (k+1)] = any(dp[(i-l) % (k+1)] and segment_valid for l in 1..k) od *= nums[n-1-i], reset to 1 if zero.
Ex: Word Break (Sliding Window Rolling Variables)
def wordBreak(s: str, wordDict: List[str]) -> bool:
n = len(s)
# Direct Length -> empty string check
if n == 0:
return True
word_set = set(wordDict)
max_len = max(map(len, wordDict)) if wordDict else 0
# Direct Variables -> initialize rolling DP window
dp = [False] * (max_len + 1)
dp[0] = True # base case: empty string
# Iterate -> 1 to n
for i in range(1, n + 1):
dp[i % (max_len + 1)] = False
# Build From Previous -> look back up to max_len positions
for l in range(1, min(i, max_len) + 1):
# Three Possibilities -> valid segmentation from previous l positions
if dp[(i - l) % (max_len + 1)] and s[i - l:i] in word_set:
dp[i % (max_len + 1)] = True
break # early stop, valid segmentation found
# Result -> final DP state
return dp[n % (max_len + 1)]1D Dynamic Programming Application: BFS Visited Memo Level Order
Pattern: Treat problem as shortest-path search; each state represents “remaining target” or “current subproblem.” Use a queue for BFS and a visited set as memoization to avoid recomputation. Use When: Problem can be framed as reaching a target with choices at each step (e.g., coin change, min steps). BFS guarantees the first solution found is minimal (shortest path). Recurrence: For each state curr, explore all valid next states curr - choice. Steps to reach next = steps[curr] + 1.
Ex: Coin Change (BFS with Memo)
def coinChange(coins, amount):
if amount == 0:
return 0
# Queue stores (remaining amount, coins used so far)
queue = deque([(amount, 0)])
visited = set([amount]) # memo: avoid revisiting same remaining amount
while queue:
rem, steps = queue.popleft()
# Explore choices
for coin in coins:
next_rem = rem - coin
# Early stop: minimal coins found
if next_rem == 0:
return steps + 1
# Valid state & not visited
if next_rem > 0 and next_rem not in visited:
visited.add(next_rem)
queue.append((next_rem, steps + 1))
# Impossible to reach target
return -170. Climbing Stairs ::3:: - Easy
Topics: Math, Dynamic Programming, Memoization
Intro
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?
| Example Input | Output |
|---|---|
| n = 2 | 2 |
| n = 3 | 3 |
Constraints:
1 ≤ n ≤ 45
Abstraction
Given a number of steps, return the number of unique ways to reach the top, by either climbing 1 or 2 steps at a time.
Pseudocode
oh! pseudocode hasn't been written yet, try another card! :)Solution 1: [BFS] BFS Path Count Over Implicit Step Graph - 1D Dynamic Programming/Optimal Iterative (Padded N+1) Rolling State Variables Bottom Up
def climbStairs(self, n: int) -> int:
# BFS Over The Implicit "Step" Graph
# - each step from 0 to n is a node in an implicit graph, no
# literal graph is built up front
# - an edge connects step i to step i+1 and step i+2, since
# taking 1 or 2 steps from i are the only legal moves forward
# Note:
# Unlike a typical "shortest path" BFS (e.g. Perfect Squares),
# here we don't want the FIRST time n is reached -- we want the
# TOTAL number of distinct paths from 0 to n. So instead of
# returning early at the first dequeue of n, we propagate a
# "ways" count forward across every edge and let the queue fully
# drain, similar to a topological-order path-counting traversal.
# BFS State Encoding:
# - each queued item is simply a step index i
# - ways[i] tracks how many distinct paths from 0 have reached
# step i so far, accumulated as new edges into i are processed
# Legal Next Moves:
# - From step i, moving to i+1 or i+2 is legal as long as it
# doesn't overshoot n
# Discovery vs. Accumulation:
# - A step is enqueued only the FIRST time it's discovered
# (standard BFS visited-set behavior), to avoid processing
# the same node's own outgoing edges more than once
# - But ways[step] keeps ACCUMULATING every time an edge into
# it is processed, even after it's already been enqueued --
# this is what correctly sums contributions from both i-1
# and i-2 reaching the same step
# Safety Insight:
# because edges only ever point to LARGER indices (i+1, i+2),
# a step's predecessors (i-1, i-2) are always discovered and
# enqueued no later than the step itself, so by the time a step
# is dequeued, all paths reaching it have already been added to
# its ways count
# Edge Case:
# n = 0 -- vacuously 1 way to "reach" the top (no steps needed)
if n == 0:
return 1
# Ways Array:
# ways[i] = number of distinct paths from step 0 to step i
# sc: O(n)
ways = [0] * (n + 1)
ways[0] = 1
# Iterative BFS Queue:
# - seed the queue with the starting step, 0
queue = deque([0])
# Self Edges and Parallel Edges Safety Tracking:
# - mark starting step as visited (discovered), so its
# outgoing edges are only ever processed once
# sc: O(n)
visited = {0}
# tc: O(n), each step dequeued and expanded exactly once
while queue:
i = queue.popleft()
# Explore Neighbors:
# - move forward by 1 step and by 2 steps
for move in (1, 2):
j = i + move
# Legal Move Check:
# - skip any move that overshoots the top
if j > n:
continue
# Accumulate:
# - every path reaching i also reaches j via this edge,
# so add i's path count onto j's
# tc: O(1)
ways[j] += ways[i]
# Discovery Check:
# - only enqueue j the first time it's discovered, so
# its own outgoing edges are expanded exactly once
if j not in visited:
visited.add(j)
queue.append(j)
# Result:
# ways[n] holds the total number of distinct paths from 0 to n,
# accumulated across every edge that ever pointed into it
res = ways[n]
# overall: tc O(n) -- each of the n+1 steps is dequeued once,
# expanding at most 2 edges
# overall: sc O(n) -- ways array, visited set, and queue
return resSolution 2: [DP] 0 to i Recursive with Memoization Top Down - 1D Dynamic Programming/DFS with Caching (Padded N+1) Top Down with Memoization
def climbStairs(self, n: int) -> int:
# Dynamic Programming (DP):
# DP is used when a problem has:
# 1. Overlapping subproblems
# 2. Optimal substructure
# Instead of recomputing subproblems, we cache results and compute
# each subproblem only once.
# Three DP Perspectives:
# 1. i -> N (Top-Down Recursive with Memoization)
# 2. 0 -> i (Bottom-Up Recursive)
# 3. 0 -> i (Bottom-Up Iterative / Tabulation)
# This solution uses 0 -> i (Top-Down Recursive with Memoization)
# State Definition:
# Let dfs(i) represent the number of ways to reach step i
# starting from step 0.
# Recurrence Relation:
# To reach step i, you can come from:
# - step i-1 (1 step)
# - step i-2 (2 steps)
# Therefore:
# dfs(i) = dfs(i - 1) + dfs(i - 2)
# Base Cases (Padded Boundaries):
# - If i == 0: ground level -> 1 valid way
# - If i == 1: first step -> 1 valid way
# Padding eliminates the need for explicit length checks.
# Memoization (Caching Overlapping Subproblems):
# Multiple paths can reach the same step i.
# Without caching, recursion becomes exponential.
# With memoization, each state i is computed once.
# Ways to climb from 0 to ith step
# sc: O(n) for memo dictionary
memo = {}
def dfs(i):
# Memo Check:
# Check: if step has been computed previously
# tc: O(1)
if i in memo:
return memo[i]
# Base Case: Ground Level
# i == 0, 1 way to reach ground level
# tc: O(1)
if i == 0:
return 1
# Base Case: First Step
# i == 1, 1 way to reach first step
# tc: O(1)
if i == 1:
return 1
# Recurrence Relation:
# Build from previous states: step i-1 and i-2
# dfs(i) = dfs(i - 1) + dfs(i - 2)
# tc: O(1) per call, overall O(n) due to memoization
memo[i] = dfs(i - 1) + dfs(i - 2)
# Return:
# Number of ways to reach step i
# tc: O(1)
return memo[i]
# Initial Call:
# Number of ways to reach step n
# tc: O(1)
res = dfs(n)
# overall: tc O(n)
# overall: sc O(n)
return resSolution 3: [DP] 0 to i Iterative Bottom Up Variables - 1D Dynamic Programming/Optimal Iterative (Padded N+1) Rolling State Variables Bottom Up
def climbStairs(self, n: int) -> int:
# Dynamic Programming (DP):
# DP is used when a problem has:
# 1. Overlapping subproblems
# 2. Optimal substructure
# Bottom-Up DP can be optimized to use constant space by only
# storing the last two computed states, since each state depends
# only on the previous two.
# Three DP Perspectives:
# 1. i -> N (Top-Down Recursive with Memoization)
# 2. 0 -> i (Top-Down Recursive / 0 → i)
# 3. 0 → i (Bottom-Up Iterative / Tabulation)
# This solution uses 0 → i (Bottom-Up Iterative with Rolling Variables)
# State Definition:
# prev2: number of ways to reach step i-2
# prev1: number of ways to reach step i-1
# curr: number of ways to reach step i
# Only two previous states are needed because dp[i] = dp[i-1] + dp[i-2]
# State Definition:
# prev2: step i-2
# prev1: step i-1
# curr: step i
# Only two previous states are needed because dp[i] = dp[i-1] + dp[i-2]
# sc: O(1)
TwoPrev = 1 # ground
OnePrev = 1 # first step
# Iterative Build (Bottom-Up Traversal):
# Start from [2, n]
# For each step i, build current number of ways from previous two
# tc: O(1) per iteration, overall O(n)
for i in range(2, n+1):
# Build From Previous:
# Number of ways to reach step i is sum of ways to reach previous two steps
curr = TwoPrev + OnePrev
# Roll Variable:
# Move values forward
TwoPrev, OnePrev = OnePrev, curr
# Result:
# Number of ways to reach step n
# tc: O(1)
res = OnePrev
# overall: tc O(n)
# overall: sc O(1)
return res746. Min Cost Climbing Stairs ::3:: - Easy
Topics: Array, Dynamic Programming
Intro
You are given an integer array cost where cost[i] is the cost of ith step on a staircase. Once you pay the cost, you can either climb one or two steps. You can either start from the step with index 0, or the step with index 1. Return the minimum cost to reach the top of the floor.
| Example Input | Output |
|---|---|
| cost = [10,15,20] | 15 |
| [1,100,1,1,1,100,1,1,100,1] | 6 |
Constraints:
2 ≤ cost.length ≤ 1000
0 ≤ cost[i] ≤ 999
Abstraction
Given a number of steps, and the cost to process a step, find the cheapest cost to climb to the top.
Pseudocode
oh! pseudocode hasn't been written yet, try another card! :)Solution 1: [BFS] BFS Cost Relaxation Over Implicit Step Graph - 1D Dynamic Programming/DFS with Caching (Direct N) Top Down with Memoization
def minCostClimbingStairs(self, cost: List[int]) -> int:
# BFS Over The Implicit "Step" Graph
# - each step from 0 to n (n = top, one past the last index) is
# an implicit node, no literal graph is built up front
# - an edge connects step i to step i+1 and step i+2, weighted
# by cost[i] -- the price paid to LEAVE step i toward either
# neighbor
# Note:
# Unlike Climbing Stairs' BFS (path counting) or Perfect Squares'
# BFS (first-arrival shortest path), this is a WEIGHTED graph, so
# plain unweighted BFS doesn't directly give minimum cost. What
# makes BFS still valid here is that every edge points to a
# STRICTLY LARGER index (i -> i+1, i -> i+2), so the graph is a
# DAG with a natural topological order = increasing step index.
# Processing nodes via a FIFO queue in discovery order happens to
# match that topological order, so relaxing edges as we dequeue
# each node still guarantees dist[i] is finalized (never improved
# again) by the time i is dequeued -- same idea as DAG shortest
# path via topological relaxation.
# BFS State Encoding:
# - each queued item is simply a step index i
# - dist[i] tracks the minimum cost accumulated to ARRIVE at
# step i so far, relaxed (improved) every time an edge into
# it is processed
# Legal Next Moves:
# - From step i, moving to i+1 or i+2 is legal as long as it
# doesn't overshoot n (the top, one past the last step)
# Discovery vs. Relaxation:
# - A step is enqueued only the FIRST time it's discovered, to
# avoid expanding the same node's outgoing edges more than once
# - But dist[step] keeps getting RELAXED (min'd) every time a
# new edge into it is processed, even after it's enqueued --
# this correctly captures the cheaper of the two incoming
# paths (from i-1 or from i-2)
# Safety Insight:
# because edges only ever point to LARGER indices, a step's
# predecessors (i-1, i-2) are always discovered and enqueued no
# later than the step itself, so by the time a step is dequeued,
# both possible incoming edges have already relaxed its dist
n = len(cost)
# Dist Array:
# dist[i] = min cost to arrive at step i
# dist[n] represents arriving at the top, one past the last step
# sc: O(n)
dist = [float('inf')] * (n + 1)
# Free Starting Steps:
# you may start at step 0 or step 1 at no entry cost
dist[0] = 0
dist[1] = 0
# Iterative BFS Queue:
# - seed the queue with both valid starting steps
queue = deque([0, 1])
# Self Edges and Parallel Edges Safety Tracking:
# - mark both starting steps as visited (discovered), so their
# outgoing edges are only ever processed once
# sc: O(n)
visited = {0, 1}
# tc: O(n), each step dequeued and expanded exactly once
while queue:
i = queue.popleft()
# Explore Neighbors:
# - move forward by 1 step and by 2 steps, paying cost[i]
# to leave step i
for move in (1, 2):
j = i + move
# Legal Move Check:
# - skip any move that overshoots the top
if j > n:
continue
# Relax:
# - arriving at j via i costs dist[i] + cost[i]; keep
# the cheaper of that vs. whatever j already has
# tc: O(1)
candidate = dist[i] + cost[i]
if candidate < dist[j]:
dist[j] = candidate
# Discovery Check:
# - only enqueue j the first time it's discovered, so
# its own outgoing edges are expanded exactly once
if j not in visited:
visited.add(j)
queue.append(j)
# Result:
# dist[n] holds the minimum cost to reach the top, having
# relaxed every edge that ever pointed into it
res = dist[n]
# overall: tc O(n) -- each of the n+1 steps is dequeued once,
# expanding at most 2 edges
# overall: sc O(n) -- dist array, visited set, and queue
return resSolution 2: [DP] i to N Recursive with Explicit Memoization Top Down - 1D Dynamic Programming/DFS with Caching (Direct N) Top Down with Memoization
def minCostClimbingStairs(self, cost: List[int]) -> int:
# Note:
# Top Down DP (memoized recursion)
# 0. Direct Length -> no need for len == 1 check, recursive direct boundary covers it
# 1. Memo Check -> computed previously
# 2. Direct Boundary -> 1st element
# 3. Direct Boundary -> 2nd element
# 4. Direct Build From Previous -> grab from previous two steps and sum paths
# 5. Memo return -> return ways, calculated once
# Result: min cost to reach n from last or 2nd to last step
n = len(cost)
memo = {}
def dfs(i):
# Memo Check -> computed previously
if i in memo:
return memo[i]
# Direct Boundary -> 1st element
if i == 0:
return cost[0]
# Direct Boundary -> 2nd element
if i == 1:
return cost[1]
# Build -> min cost to reach + cost to continue
memo[i] = cost[i] + min(dfs(i-1), dfs(i-2))
return memo[i]
# res -> reach n by continuing from nth or nth-1 step (since we step 1 or 2 steps)
res = min(dfs(n-1), dfs(n-2))
# overall: time complexity O(n)
# overall: space complexity O(n) (memo + recursive stack)
return resSolution 3: [DP] 0 to i Iterative Bottom Up Variables - 1D Dynamic Programming/Optimal Iterative (Direct N) Rolling State Variables Bottom Up
def minCostClimbingStairs(self, cost: List[int]) -> int:
# Note:
# Bottom Up Variables
# 0. Direct Length -> no need for len == 1 check, description says min length == 2
# 1. Direct Variables -> 1st + 2nd element
# 2. Iterate -> 2 to n-1
# 3. Build From Previous -> grab from previous two steps and sum paths
# 3. Roll Variables -> Iterate Variables
# Result: min cost to reach n via continuing from last or 2nd to last step (n-1, or n-2)
# 0. Direct Length -> no need for len == 1 check, description says min length == 2
n = len(cost)
# Direct Variables -> 1st + 2nd element
prev2, prev1 = cost[0], cost[1]
# Iterate -> 2 to n-1
for i in range(2, n):
# Build From Previous -> grab from previous two steps and sum paths
curr = cost[i] + min(prev1, prev2)
# Roll Variables -> Iterate Variables
prev2, prev1 = prev1, curr
# res -> min cost to reach n
res = min(prev1, prev2)
# overall: time complexity O(n)
# overall: space complexity O(1)
return res1137. Nth Tribonacci Number ::3:: - Easy
Topics: Math, Dynamic Programming, Memoization
Intro
The Tribonacci sequence Tn is defined as follows: T0 = 0, T1 = 1, T2 = 1, Tn+3 = Tn + Tn+1 + Tn+2 for n >= 0. Given n, return the value of Tn.
| Example Input | Output |
|---|---|
| n = 4 | 4 |
| n = 25 | 1389537 |
Constraints:
0 ≤ n ≤ 37
The answer is guaranteed to fit within a 32-bit integer, ie. answer ≤ 231 - 1
Abstraction
Find the Tribonacci for n.
Pseudocode
oh! pseudocode hasn't been written yet, try another card! :)Solution 1: [BFS] BFS Value Propagation Over Implicit Index - 1D Dynamic Programming/DFS with Caching (Direct N) Top Down with Memoization
def tribonacci(self, n: int) -> int:
# BFS Over The Implicit "Index" Graph
# - each index from 0 to n is an implicit node, no literal
# graph is built up front
# - an edge connects index i to index i+1, i+2, and i+3, since
# T(i) contributes to each of those three future sums
# Note:
# T0, T1, T2 are fixed BASE CASES, not sums built from earlier
# values -- they must never receive propagated contributions from
# each other. Only indices >= 3 are actually defined by the
# recurrence, so propagation is only allowed to land on j >= 3.
# Without this guard, node 0's outgoing edge into node 2 (and
# node 1's edge into node 2) incorrectly adds onto T2's seeded
# value before it's ever used, corrupting every downstream sum.
if n == 0:
return 0
if n == 1:
return 1
if n == 2:
return 1
# sc: O(n)
value = [0] * (n + 1)
value[0] = 0
value[1] = 1
value[2] = 1
queue = deque([0, 1, 2])
# sc: O(n)
visited = {0, 1, 2}
# tc: O(n), each index dequeued and expanded exactly once
while queue:
i = queue.popleft()
for move in (1, 2, 3):
j = i + move
# Legal Move Check:
# - skip overshoot past n
# - skip landing on a fixed base index (0, 1, 2), since
# those are seeded directly, never accumulated into
if j > n or j < 3:
continue
value[j] += value[i]
if j not in visited:
visited.add(j)
queue.append(j)
res = value[n]
# overall: tc O(n)
# overall: sc O(n)
return resSolution 2: [DP] i to N Recursive with Explicit Memoization Top Down - 1D Dynamic Programming/DFS with Caching (Direct N) Top Down with Memoization
def tribonacci(self, n: int) -> int:
# Note:
# Top Down DP (memoized recursion)
# 1. Memo Check -> computed previously
# 2. Direct Boundary -> 1st element
# 3. Direct Boundary -> 2nd element
# 4. Direct Boundary -> 3rd element
# 5. Build From Previous -> grab from previous two steps and sum paths
# 6. Memo return -> return ways, calculated once
# Result: T_i in Tribonacci sequence
memo = {}
def dfs(i):
# Memo Check -> computed previously
if i in memo:
return memo[i]
# Boundary -> 1st element
if i == 0:
return 0
# Boundary -> 2nd element
if i == 1:
return 1
# Boundary -> 3rd element
if i == 2:
return 1
# Build From Previous -> grab from previous two steps and sum paths
memo[i] = dfs(i-1) + dfs(i-2) + dfs(i-3)
return memo[i]
# res -> T_n value
res = dfs(n)
# overall: time complexity O(n)
# overall: space complexity O(n) (memo + recursion stack)
return resSolution 3 [DP] 0 to i Iterative Bottom Up Variables - 1D Dynamic Programming/Optimal Iterative (Direct N) Rolling State Variables Bottom Up
def tribonacci(self, n: int) -> int:
# Note:
# this is a unique case where Direct/N, requires an array of (N+1)
# since we have 0 -> N fib solutions
# Note:
# Iterative Bottom Up Variables
# 1. Direct Boundary -> need len == 1 check, elements 2 and 3 may not exist
# 2. Direct Variables -> 1st + 2nd + 3rd elements
# 3. Iterate -> 3 to n
# 4. Build From Previous -> grab from previous two steps and sum paths
# 5. Roll Variables -> Iterate Variables
# Result: T_n in Tribonacci sequence
# Direct Boundary -> need len == 1 check, elements 2 and 3 may not exist
if n == 0:
return 0
if n == 1:
return 1
# Direct Variables -> 1st + 2nd + 3rd elements
ThreePrev, TwoPrev, OnePrev = 0, 1, 1
# Iterate -> 3 to n
for i in range(3, n+1):
# Build From Previous -> grab from previous two steps and sum paths
curr = ThreePrev + TwoPrev + OnePrev
# Roll Variables -> Iterate Variables
ThreePrev, TwoPrev, OnePrev = TwoPrev, OnePrev, curr
# res -> nth tribonacci
res = OnePrev
# overall: time complexity O(n)
# overall: space complexity O(1)
return res118. Pascals Triangle ::2:: - Easy
Topics: Array, Dynamic Programming
Intro
Given an integer numRows, return the first numRows of Pascal's triangle. In Pascal's triangle, each number is the sum of the two numbers directly above it as shown:
| Example Input | Output |
|---|---|
| numRows = 5 | [[1],[1,1],[1,2,1],[1,3,3,1],[1,4,6,4,1]] |
| numRows = 1 | [[1]] |
Constraints:
1 ≤ numRows ≤ 30
Abstraction
test
Pseudocode
oh! pseudocode hasn't been written yet, try another card! :)Solution 1: [BFS] BFS Level Order Row Construction - Math and Geometry/Math and Geometry
def generate(self, numRows: int) -> List[List[int]]:
# BFS Over The Implicit "Position" Graph
# - each cell (row, col) in the triangle is an implicit node,
# no literal graph is built up front
# - an edge connects (row, col) to (row+1, col) and to
# (row+1, col+1), since each value contributes to the TWO
# values directly below it in the next row
# Note:
# Unlike the earlier BFS solutions (Climbing Stairs, Tribonacci)
# where BFS levels didn't naturally align with the problem's
# structure, Pascal's Triangle is a genuinely natural fit: the
# triangle's ROWS *are* BFS levels. Draining the queue level by
# level and building each full row before moving to the next is
# exactly what "process one row, then the next" already means --
# no forcing required here.
# BFS State Encoding:
# - each queued item tracks a single piece of info:
# - value[(row, col)] holds the finalized value at that
# position, accumulated from contributions of its one or two
# parents in the row above before being read
# Legal Next Moves:
# - From (row, col), propagate down-left to (row+1, col) and
# down-right to (row+1, col+1), as long as row+1 < numRows
# Discovery vs. Accumulation:
# - A position is enqueued only the FIRST time it's discovered,
# so its own outgoing edges are only ever expanded once
# - But value[position] keeps ACCUMULATING every time a
# contribution into it arrives, even after it's enqueued --
# this correctly sums BOTH parent contributions before the
# position is itself dequeued and propagated forward
# Safety Insight:
# since edges only ever point to the NEXT row, every position in
# row i is fully discovered and enqueued before row i+1 begins
# processing (standard level-order guarantee), so by the time a
# position is dequeued, both of its parent contributions have
# already landed in its value
# Edge Case:
# a single row is just [1], no propagation needed
if numRows == 1:
return [[1]]
# Result Rows:
# sc: O(numRows^2), total cells across the whole triangle
res = [[] for _ in range(numRows)]
# Value Map:
# value[(row, col)] accumulates contributions from parent cells
# sc: O(numRows^2)
value = {(0, 0): 1}
# Iterative BFS Queue:
# - seed the queue with the single apex position
queue = deque([(0, 0)])
# Self Edges and Parallel Edges Safety Tracking:
# - mark starting position as visited (discovered)
# sc: O(numRows^2)
visited = {(0, 0)}
# tc: O(numRows^2), every cell dequeued and expanded exactly once
while queue:
row, col = queue.popleft()
# Record Finalized Value:
# - all contributions into (row, col) have already arrived
# (guaranteed by level-order processing), safe to record
res[row].append(value[(row, col)])
# No further propagation needed once the last row is reached
if row == numRows - 1:
continue
# Explore Neighbors:
# - propagate this value down-left and down-right
for nextCol in (col, col + 1):
nextPos = (row + 1, nextCol)
# Accumulate:
# - this cell's value contributes to both children
# directly below it
# tc: O(1)
value[nextPos] = value.get(nextPos, 0) + value[(row, col)]
# Discovery Check:
# - only enqueue nextPos the first time it's
# discovered, so it propagates forward exactly once
if nextPos not in visited:
visited.add(nextPos)
queue.append(nextPos)
# overall: tc O(numRows^2) -- total cells across all rows sum to
# 1+2+3+...+numRows = O(numRows^2)
# overall: sc O(numRows^2) -- value map, visited set, queue, and
# output all bounded by total cell count
return resSolution 2: [DP] Build Row By Row From Previous Row - Math and Geometry/Math and Geometry
def generate(self, numRows: int) -> List[List[int]]:
# Build Pascal's Triangle row by row, each row derived from the last
# Note:
# Every row starts and ends with 1. Every interior value is the
# sum of the two values directly above it in the PREVIOUS row --
# so each row only ever depends on the row immediately before it,
# never anything further back.
# 1. Start each new row with a leading 1
# 2. For each interior position, sum the two adjacent values from
# the previous row (triangle[i-1][j-1] and triangle[i-1][j])
# 3. Close each row (past the first) with a trailing 1
# Result -> list of numRows rows forming Pascal's Triangle
# Build Ex (numRows=5):
# row 0: [1]
# row 1: [1, 1]
# row 2: [1, 2, 1] <- 2 = row1[0] + row1[1] = 1+1
# row 3: [1, 3, 3, 1] <- 3 = row2[0] + row2[1] = 1+2
# row 4: [1, 4, 6, 4, 1] <- 6 = row3[1] + row3[2] = 3+3
dp = []
# Build each row from the previous row
# tc: O(numRows^2) -- row i has i+1 elements, summed over all rows
for i in range(numRows):
dpRow = [1] # every row starts with a leading 1
# Fill interior values from the row above
for j in range(1, i):
dpRow.append(dp[i - 1][j - 1] + dp[i - 1][j])
# Close the row with a trailing 1 (skip for row 0)
if i > 0:
dpRow.append(1)
dp.append(dpRow)
# overall: tc O(numRows^2)
# overall: sc O(numRows^2)
return dp119. Pascals Triangle II ::2:: - Easy
Topics: Array, Dynamic Programming
Intro
Given an integer rowIndex, return the rowIndexth (0-indexed) row of the Pascal's triangle. In Pascal's triangle, each number is the sum of the two numbers directly above it as shown: Follow up: Could you optimize your algorithm to use only O(rowIndex) extra space?
| Example Input | Output |
|---|---|
| rowIndex = 3 | [1,3,3,1] |
| rowIndex = 0 | [1] |
Constraints:
0 ≤ rowIndex ≤ 33
Abstraction
test
Pseudocode
oh! pseudocode hasn't been written yet, try another card! :)Solution 1: [BFS] In Place Right to Left Row Update - Math and Geometry/Math and Geometry
def getRow(self, rowIndex: int) -> List[int]:
# BFS Over The Implicit "Position" Graph
# - each cell (row, col) in the triangle is an implicit node,
# no literal graph is built up front
# - an edge connects (row, col) to (row+1, col) and to
# (row+1, col+1), since each value contributes to the TWO
# values directly below it in the next row
# Note:
# Same natural fit as #118 -- the triangle's rows ARE BFS levels,
# so draining the queue level by level already matches "process
# one row, then the next." The difference here is #119 only
# needs the FINAL row returned, not every row stored -- so once
# a cell has propagated its value to both children, its own
# entry is deleted from the value map. This keeps the value map
# (and therefore total extra space) bounded to O(rowIndex) at
# any given moment, rather than O(rowIndex^2) for the whole
# triangle, directly answering the follow-up's space constraint.
# BFS State Encoding:
# - each queued item tracks a single piece of info:
# - value[(row, col)] holds the finalized value at that
# position, accumulated from contributions of its one or two
# parents in the row above before being read
# Legal Next Moves:
# - From (row, col), propagate down-left to (row+1, col) and
# down-right to (row+1, col+1), as long as row+1 <= rowIndex
# Discovery vs. Accumulation:
# - A position is enqueued only the FIRST time it's discovered,
# so its own outgoing edges are only ever expanded once
# - value[position] keeps ACCUMULATING every time a
# contribution into it arrives, even after it's enqueued --
# this correctly sums BOTH parent contributions before the
# position is itself dequeued and propagated forward
# Space Pruning:
# - once a cell has been dequeued and propagated to both of
# its children, its entry is no longer needed by anything
# else in the graph (no cell reads a grandparent's value
# directly) -- so it's safe to delete, keeping the value map
# from growing past O(rowIndex) entries at once
# Safety Insight:
# since edges only ever point to the NEXT row, every position in
# row i is fully discovered and enqueued before row i+1 begins
# processing (standard level-order guarantee), so by the time a
# position is dequeued, both of its parent contributions have
# already landed in its value
# Edge Case:
# row 0 is just [1], no propagation needed
if rowIndex == 0:
return [1]
# Value Map:
# value[(row, col)] accumulates contributions from parent cells;
# entries are deleted once fully propagated, bounding size to
# O(rowIndex) at any moment
# sc: O(rowIndex)
value = {(0, 0): 1}
# Iterative BFS Queue:
# - seed the queue with the single apex position
# sc: O(rowIndex)
queue = deque([(0, 0)])
# Self Edges and Parallel Edges Safety Tracking:
# - mark starting position as visited (discovered)
# sc: O(rowIndex)
visited = {(0, 0)}
# Target Row Buffer:
# only the target row's values are collected for the final result
# sc: O(rowIndex)
result_row = [0] * (rowIndex + 1)
# tc: O(rowIndex^2), every cell across all rows dequeued once
while queue:
row, col = queue.popleft()
curr_value = value[(row, col)]
# Target Row Reached:
# - record this cell's finalized value into the answer
if row == rowIndex:
result_row[col] = curr_value
# Prune:
# - this cell's value has now been read; if it's not the
# target row it will never be needed again once
# propagated below, so remove it to bound space
del value[(row, col)]
# No further propagation needed once the target row is reached
if row == rowIndex:
continue
# Explore Neighbors:
# - propagate this value down-left and down-right
for nextCol in (col, col + 1):
nextPos = (row + 1, nextCol)
# Accumulate:
# - this cell's value contributes to both children
# directly below it
# tc: O(1)
value[nextPos] = value.get(nextPos, 0) + curr_value
# Discovery Check:
# - only enqueue nextPos the first time it's
# discovered, so it propagates forward exactly once
if nextPos not in visited:
visited.add(nextPos)
queue.append(nextPos)
# overall: tc O(rowIndex^2) -- total cells across all rows sum to
# 1+2+...+(rowIndex+1) = O(rowIndex^2)
# overall: sc O(rowIndex) -- value map, visited set, and queue
# never hold more than roughly one row's worth of entries at a
# time due to pruning, matching the follow-up's O(rowIndex)
# extra-space requirement (excluding the O(rowIndex) output
# itself)
return result_rowSolution 2: [DP] In Place Right to Left Row Update - Math and Geometry/Math and Geometry
def getRow(self, rowIndex: int) -> List[int]:
# Build ONLY the target row of Pascal's Triangle, updating a
# single list in place instead of storing every previous row
# Note:
# stores every row because it needs to RETURN all of them.
# Here we only need the FINAL row, so we can reuse one list and
# update it in place each iteration -- O(rowIndex) space instead
# of O(rowIndex^2).
#
# The trick: update each row RIGHT TO LEFT, not left to right.
# row[j] = row[j] + row[j-1] needs BOTH of those values from the
# PREVIOUS row (not yet overwritten this iteration). If we walked
# left to right, row[j-1] would already have been overwritten
# with the CURRENT row's value by the time we read it -- corrupting
# the calculation. Walking right to left guarantees row[j-1] is
# still the old (previous row's) value when we use it.
# 1. Start with row = [1] (row 0)
# 2. For each subsequent row: extend the list by one (new trailing 1),
# then update interior values right to left using the OLD values
# still sitting to the left, which haven't been touched yet
# Result -> the target row, built with O(rowIndex) space
# Build Ex (rowIndex=3):
# row 0: [1]
# row 1: [1, 1] <- extend, then no interior to update
# row 2: [1, 2, 1] <- j=1: row[1]+row[0] = 1+1 = 2
# row 3: [1, 3, 3, 1] <- j=2: row[2]+row[1] = 1+2 = 3 (old values)
# j=1: row[1]+row[0] = 2+1 = 3
row = [1]
# tc: O(rowIndex^2) -- row i has i+1 elements, summed over all rows
for i in range(1, rowIndex + 1):
row.append(1) # extend row by one, trailing 1 placeholder
for j in range(len(row) - 2, 0, -1):
row[j] += row[j - 1]
# overall: tc O(rowIndex^2)
# overall: sc O(rowIndex)
return row4050. Minimum Days to Score Exactly N Points ::3:: - Medium
Topics: BFS Shortest Path Unweighted Graph, Math, Dynamic Programming, Breadth First Search, Knapsack Problem, Complete Knapsack, Rule Based Graph, Graph Theory
Intro
You are given an integer n representing a target score. Your score starts at 0, and each day you either earn points or skip. Points are earned during a streak. On the first day of a streak you earn 1 point, on the second day 2 points, on the third day 3 points, and so on. Skipping a day earns nothing and resets the streak, so the next time you earn points, you start from 1 again. Return the minimum number of days, including any skipped days, needed to reach a score of exactly n.
| Example Input | Output |
|---|---|
| n = 2 | 3 |
| n = 9 | 6 |
| n = 12 | 7 |
Constraints:
1 ≤ n ≤ 10^5
Abstraction
Find t
Pseudocode
Solution 1: [BFS] BFS Over Implicit Remaining Sum Graph [Time Out] - Graph/BFS Level Order Shortest Path
def minDays(self, n: int) -> int:
# BFS Over The Implicit "Score So Far" Graph
# - each (score, streakVal) pair is an implicit node, no literal
# graph is built up front
# - an edge connects (score, streakVal) to (score + streakVal,
# streakVal + 1) by "earning" today, or to (score, 1) by
# "skipping" today and resetting the streak
# - every edge costs exactly 1 day, so BFS level order guarantees
# the first time score == n is reached, that level number is
# the minimum number of days
# BFS State Encoding:
# - each queued item tracks 2 pieces of info:
# - score => total points earned so far
# - streakVal => points the NEXT earn-day would award
# Legal Next Moves:
# - Earn: only legal if score + streakVal <= n, since
# overshooting past n can never be undone
# - Skip: always legal, resets streakVal back to 1
# Self Edges and Parallel Edges Safety:
# - Once a (score, streakVal) state has been visited, BFS has
# already reached it via the fewest possible days, so we can
# safely skip requeuing it
# Edge Case:
# n = 0 needs zero days, score already matches target
if n == 0:
return 0
# Iterative BFS Queue:
# - seed the queue with the starting state: score 0, next earn
# day would award 1 point
# sc: O(n) worst case, queue can hold many in-flight states
queue = deque([(0, 1)])
# Self Edges and Parallel Edges Safety Tracking:
# - mark starting state as visited
# sc: O(n * sqrt(n)) worst case, bounded by distinct (score,
# streakVal) pairs reachable
visited = {(0, 1)}
# BFS Depth Level:
# - tracking min number of days used to reach current depth level
days = 0
while queue:
days += 1
# Level By Level Expansion:
# - drain entire current level before incrementing days
# - guarantees every state discovered in this pass used the
# same number of days from the start
for _ in range(len(queue)):
score, streakVal = queue.popleft()
# Move 1 - Earn:
# - only legal if it doesn't overshoot n
if score + streakVal <= n:
nextScore = score + streakVal
# Check:
# - if we've hit exactly n, we are guaranteed to
# have the minimum number of days
if nextScore == n:
return days
nextState = (nextScore, streakVal + 1)
# Early Pruning:
# - enqueue new state if not already visited
if nextState not in visited:
visited.add(nextState)
queue.append(nextState)
# Move 2 - Skip:
# - always legal, resets streak back to 1
skipState = (score, 1)
if skipState not in visited:
visited.add(skipState)
queue.append(skipState)
# overall: tc O(n * sqrt(n)) worst case -- bounded by distinct
# (score, streakVal) states, streakVal capped around O(sqrt(n))
# since streaks grow triangularly
# overall: sc O(n * sqrt(n)) -- visited set and queue hold up to
# that many states
return -1Solution 2: [DP] [DFS] Top Down Recursive With Memoization [Time Out] - 1D Dynamic Programming/DFS With Caching Top Down
def minDays(self, n: int) -> int:
# Note:
# Minimum days to reach exactly n points using Top Down
# Recursion with Memoization.
# Streak Choice:
# From any current score, choose to run a NEW streak (after an
# optional single skip day to reset, unless this is the very
# first action of the game) for some number of days, earning
# points 1, 2, 3, ... for each day of that streak, then continue
# from the resulting score. Every possible streak length that
# doesn't overshoot n is tried -- including stopping mid-streak,
# since skipping before a streak naturally ends may be required
# to land exactly on n instead of overshooting it.
# Subproblem Framing:
# dfs(score) depends on dfs(score + 1), dfs(score + 1+2),
# dfs(score + 1+2+3), ... for every streak length that keeps
# score + added <= n. These are LARGER scores than the current
# one -- recursion moves forward toward n, unlike a typical
# "remaining count down to 0" recursion, but it's still
# well-founded since score strictly increases on every call and
# is capped at n.
# Top Down Recursion With Memoization
# Each distinct score is a subproblem, explored recursively by
# trying every streak length as the "next" block of days played.
# dfs(score) returns the fewest days needed to go from score to
# exactly n, built from the answers to subproblems with larger
# (closer to n) scores already solved deeper in the recursion.
# Memoization:
# since the same score can be reached through many different
# sequences of streaks/skips, caching each result the first time
# it's computed avoids recomputing that subproblem again
# Safety Insight:
# once score is in memo, we know every streak choice from it has
# already been explored, so we can return the cached minimum
# directly instead of re-branching
# Sentinel:
# represents "not possible (yet)", so any real day count will
# always be smaller and win the min() comparison
sentinel = float('inf')
# Memo Cache:
# memo[score] = min days needed to go from score to exactly n
# sc: O(n)
memo = {}
def dfs(score):
# Boundary Check:
# exact target reached, no more days needed
if score == n:
return 0
# Memo Check:
# subproblem already solved, return cached result
if score in memo:
return memo[score]
curr_min = sentinel
streak = 1
added = 0
# Explore Choices:
# try every streak length as the next block of days played
while score + added + streak <= n:
added += streak
# Reset Day:
# a skip day is needed to start a fresh streak UNLESS
# this is the very first action of the game (score == 0)
reset = 0 if score == 0 else 1
# Check if using this streak length beats the current best
use_streak = dfs(score + added) + streak + reset
curr_min = min(curr_min, use_streak)
streak += 1
# All streak choices explored, cache and return the best found
memo[score] = curr_min
return memo[score]
# Result:
# kick off recursion from the starting score of 0
res = dfs(0)
# overall: tc O(n * sqrt(n)) -- O(n) distinct scores, each trying
# up to O(sqrt(n)) streak lengths before the streak's running
# total exceeds n
# overall: sc O(n) -- memo cache plus recursion stack depth
return resSolution 3: [DP] Bottom Up Iterative Tabulation - 1D Dynamic Programming/Iterative Tabulation Bottom Up
def minDays(self, n: int) -> int:
# Note:
# Minimum days to reach exactly n points using Bottom Up
# Iterative Tabulation.
# DP Table:
# dp[score] = fewest days needed to go from score to exactly n
# dp[n] = 0, ..., dp[0] = final answer
# Bottom Up Iterative Tabulation
# Each index score in dp represents a subproblem already solved
# for every LARGER score closer to n by the time it's needed.
# dp[score] is computed by trying every streak length as the next
# block of days played, reusing the already-solved answer for the
# resulting larger score at dp[score + added].
# Build Order:
# since dp[score] only ever depends on dp[score'] for score' >
# score, solving strictly from n down to 0 guarantees every
# dependency is already filled in by the time it's needed
# Safety Insight:
# once dp[score+1] through dp[n] are finalized, we know every
# streak combination for those larger scores has already been
# explored, so dp[score] can be built directly from those results
# Sentinel:
# represents "not possible (yet)", so any real day count will
# always be smaller and win the min() comparison
sentinel = float('inf')
# DP Array:
# dp[score] = min days needed to go from score to exactly n
# dp[n] = 0, target already reached, no more days needed
# sc: O(n)
dp = [sentinel] * (n + 1)
dp[n] = 0
# Iterate n-1 Down To 0:
# solve every subscore in decreasing order
# tc: O(n * sqrt(n))
for score in range(n - 1, -1, -1):
streak = 1
added = 0
# Explore Choices:
# try every streak length as the next block of days played
while score + added + streak <= n:
added += streak
# Reset Day:
# a skip day is needed to start a fresh streak UNLESS
# this is the very first action of the game (score == 0)
reset = 0 if score == 0 else 1
# Check if using this streak length beats the current best
use_streak = dp[score + added] + streak + reset
dp[score] = min(dp[score], use_streak)
streak += 1
# Result:
# dp[0] holds the fewest days needed starting from 0 points
res = dp[0]
# overall: tc O(n * sqrt(n)) -- O(n) scores, each trying up to
# O(sqrt(n)) streak lengths before the streak's running total
# exceeds n
# overall: sc O(n)
return res279. Perfect Squares ::3:: - Medium
Topics: BFS Shortest Path Unweighted Graph, Math, Dynamic Programming, Breadth First Search, Knapsack Problem, Complete Knapsack, Rule Based Graph, Graph Theory
Intro
Given an integer n, return the least number of perfect square numbers that sum to n. A perfect square is an integer that is the square of an integer; in other words, it is the product of some integer with itself. For example, 1, 4, 9, and 16 are perfect squares while 3 and 11 are not.
| Example Input | Output |
|---|---|
| n = 12 | 3 |
| n = 13 | 2 |
Constraints:
1 ≤ n ≤ 10^4
Abstraction
Find t
Pseudocode
Solution 1: [BFS] BFS Over Implicit Remaining Sum Graph - Graph/BFS Level Order Shortest Path
def numSquares(self, n: int) -> int:
# BFS Over The Implicit "Remaining Sum" Graph
# - each integer from 0 to n is a node in an implicit graph, with no literal graph built up front
# - an edge connects value v to value v - s for every perfect square s <= v,
# since subtracting one perfect square from the remaining total is a single "move" toward reaching 0
# BFS level by level:
# - the first time 0 is reached that level number is guaranteed to be the minimum number
# of perfect squares used.
# BFS State Encoding:
# - each queued item tracks a single piece of info:
# - remaining => the value still left to reduce to 0
# Legal Next Move:
# - From a state (remaining count), only subtracting a perfect square <= remaining is legal,
# since overshooting past 0 is never a valid move.
# Self Edges and Parallel Edges Safety:
# - Once a remaining value has been visited,
# we know BFS has already reached it via the fewest possible squares,
# so we can safely skip requeuing it.
# Edge Case:
# n is already a perfect square sum of itself, e.g. n = 0
if n == 0:
return 0
# Precompute Perfect Squares:
# - generate every perfect square up to n once,
# to be used as the "move set" at every level instead of recomputing per node
# tc: O(sqrt(n))
squares = []
i = 1
while i * i <= n:
squares.append(i * i)
i += 1
# Iterative BFS Queue:
# - seed the queue with n itself, 0 squares used so far
queue = deque([n])
# Self Edges and Parallel Edges Safety Tracking:
# - mark starting remaining value as visited
# sc: O(n)
visited = {n}
# BFS Depth Level
# - tracking min num of perfect squares used to reach current depth level
count = 0
while queue:
count += 1
# Level By Level Expansion:
# - drain entire current level before incrementing count
# - guarantees for every remaining value discovered in this pass to have
# the same number of squares (depth) used from n
for _ in range(len(queue)):
# Remaining value previous jump:
remaining = queue.popleft()
# Explore Neighbors:
# - subtract every perfect square that doesn't overshoot
# the current remaining value
# - squares is sorted ascending, so once sq > remaining
# every later square would too
for sq in squares:
if sq > remaining:
break
nextRemaining = remaining - sq
# Check:
# - if we've hit exactly 0, we are guaranteed to have
# the minimum number of perfect squares
if nextRemaining == 0:
return count
# Early Pruning:
# - enque new state (nextRemaining) if not already visited
if nextRemaining not in visited:
visited.add(nextRemaining)
queue.append(nextRemaining)
# All reachable remaining values processed without hitting 0,
# which can't actually happen since 0 is always reachable by
# repeatedly subtracting 1x1 squares
# overall: tc O(n * sqrt(n))
# overall: sc O(n)
return -1Solution 2: [DP] [DFS] Top Down Recursive With Memoization - 1D Dynamic Programming/DFS With Caching Top Down
def numSquares(self, n: int) -> int:
# Note:
# Minimum perfect squares that sum to a target
# using Top Down Recursion with Memoization
# Squares:
# n = 12, perfect squares <= 12: [1, 4, 9]
# Subproblem Framing:
# dfs(12) depends on dfs(11), dfs(8), dfs(3)
# dfs(11) depends on dfs(10), dfs(7), dfs(2)
# ... overlapping subproblems, memoized to avoid recompute
# Top Down Recursion With Memoization
# Each distinct remaining value is a subproblem, explored
# recursively by trying every perfect square as the "last"
# square used. dfs(curr_remaining) returns the fewest perfect
# squares needed to make curr_remaining, built from the answers
# to smaller subproblems already solved deeper in the recursion.
# Memoization:
# since the same remaining value can be reached through many
# different combinations of squares, caching each result the
# first time it's computed avoids recomputing that subproblem again
# Safety Insight:
# once curr_remaining is in memo, we know every square
# combination reaching it has already been explored, so we can
# return the cached minimum directly instead of re-branching
# Sentinel:
# represents "not possible (yet)", so any real square count will
# always be smaller and win the min() comparison
sentinel = float('inf')
# Precompute Perfect Squares:
# generate every perfect square up to n once, reused as the
# "move set" at every recursive call instead of recomputing
# tc: O(sqrt(n))
squares = []
i = 1
while i * i <= n:
squares.append(i * i)
i += 1
# Memo Cache:
# memo[curr_remaining] = min squares needed to make curr_remaining
# sc: O(n)
memo = {}
def dfs(curr_remaining):
# Memo Check:
# subproblem already solved, return cached result
if curr_remaining in memo:
return memo[curr_remaining]
# Boundary Check:
# exact remaining reached, no more squares needed
if curr_remaining == 0:
return 0
curr_min = sentinel
# Explore Choices:
# try using each perfect square as the next square taken
for sq in squares:
# Early Prune:
# stop once the square overshoots past 0, since squares
# is sorted ascending and every later square would too
if sq > curr_remaining:
break
# Check if using this square beats the current best
use_square = dfs(curr_remaining - sq)
curr_min = min(curr_min, use_square + 1)
# All square choices explored, cache and return the best found
memo[curr_remaining] = curr_min
return memo[curr_remaining]
# Result:
# kick off recursion from the full value n
res = dfs(n)
# overall: tc O(n * sqrt(n))
# overall: sc O(n)
return resSolution 3: [DP] Bottom Up Iterative Tabulation - 1D Dynamic Programming/Iterative Tabulation Bottom Up
def numSquares(self, n: int) -> int:
# Note:
# Minimum perfect squares that sum to a target
# using Bottom Up Iterative Tabulation
# Squares:
# n = 12, perfect squares <= 12: [1, 4, 9]
# DP Table:
# dp[v] = fewest squares needed to make value v
# dp[0] = 0, dp[1] = 1, dp[4] = 1, ..., dp[12] = 3
# Bottom Up Iterative Tabulation
# Each index v in dp represents the smallest subvalue solved
# so far, built up from 0 to n. dp[v] is computed by trying
# every perfect square as the "last" square used, reusing the
# already-solved answer for the smaller remaining value at
# dp[v - sq].
# Build Order:
# since dp[v] only ever depends on dp[v - sq] for sq < v,
# solving strictly left to right guarantees every dependency
# is already filled in by the time it's needed
# Safety Insight:
# once dp[0] through dp[v - 1] are finalized, we know every
# square combination for smaller values has already been
# explored, so dp[v] can be built directly from those results
# Sentinel:
# represents "not possible (yet)", so any real square count will
# always be smaller and win the min() comparison
sentinel = float('inf')
# Precompute Perfect Squares:
# generate every perfect square up to n once, reused as the
# "move set" at every iteration instead of recomputing
# tc: O(sqrt(n))
squares = []
i = 1
while i * i <= n:
squares.append(i * i)
i += 1
# DP Array:
# dp[v] = min squares needed to make value v
# dp[0] = 0, no squares needed to make value 0
# sc: O(n)
dp = [0] * (n + 1)
# Iterate 1 To n:
# solve every subvalue in increasing order
# tc: O(n * sqrt(n))
for v in range(1, n + 1):
curr_min = sentinel
# Explore Choices:
# try using each perfect square as the next square taken
for sq in squares:
# Early Prune:
# stop once the square overshoots past v, since squares
# is sorted ascending and every later square would too
if sq > v:
break
# Check if using this square beats the current best
use_square = dp[v - sq] + 1
curr_min = min(curr_min, use_square)
dp[v] = curr_min
# Result:
# n is always reachable (worst case, all 1x1 squares), so no
# sentinel check needed here unlike Coin Change
res = dp[n]
# overall: tc O(n * sqrt(n))
# overall: sc O(n)
return res322. Coin Change ::3:: - Medium
Topics: Array, Dynamic Programming, Breadth First Search
Intro
You are given an integer array coins representing coins of different denominations and an integer amount representing a total amount of money. Return the fewest number of coins that you need to make up that amount. If that amount of money cannot be made up by any combination of the coins, return -1. You may assume that you have an infinite number of each kind of coin.
| Example Input | Output |
|---|---|
| coins = [1,2,5], amount = 11 | 3 |
| coins = [2], amount = 3 | -1 |
| coins = [1], amount = 0 | 0 |
Constraints:
1 ≤ coins.length ≤ 12
1 ≤ coins[i] ≤ 231 - 1
0 ≤ amount ≤ 104
Abstraction
Return min number of coins needed to reach an amount
Pseudocode
oh! pseudocode hasn't been written yet, try another card! :)Solution 1: [BFS] BFS Memo Coin Number Level Order Search - 1D Dynamic Programming/BFS Visited Memo Level Order
def coinChange(self, coins: List[int], amount: int) -> int:
# BFS Over The Implicit "Remaining Amount" Graph
# - each integer from 0 to amount is a node in an implicit graph,
# with no literal graph built up front.
# - an edge connects value r to value r - c for every coin
# denomination c <= r, since subtracting one coin from the
# remaining amount is a single "move" toward reaching 0.
# BFS explores remaining amounts level by level:
# - the first time 0 is reached
# - that level number is guaranteed to be the minimum number
# of coins used.
# BFS State Encoding:
# - each queued item tracks two pieces of info:
# - rem => the amount still left to reduce to 0
# - steps => the number of coins used to reach rem so far
# Legal Next Move:
# - From a state (rem, steps),
# - only subtracting a coin <= rem is legal,
# - since overshooting past 0 is never a valid move.
# Self Edges and Parallel Edges Safety:
# - Once a remaining value has been visited, we know BFS has
# already reached it via the fewest possible coins,
# so we can safely skip requeuing it.
# Edge Case:
# amount is already 0, no coins needed
if amount == 0:
return 0
# Iterative BFS Queue:
# - seed the queue with amount itself, 0 coins used so far
queue = deque([(amount, 0)])
# Self Edges and Parallel Edges Safety Tracking:
# - mark starting remaining value as visited
# sc: O(amount)
visitedValues = {amount}
while queue:
# Remaining value previous jump:
rem, steps = queue.popleft()
# Explore Neighbors:
# - try to subtract every coin that doesn't overshoot rem
for coin in coins:
# Curr coin step
nextRem = rem - coin
# Check:
# - if we've hit exactly 0, we are guaranteed to have
# the minimum number of coins
if nextRem == 0:
return steps + 1
# Early Pruning:
# - enque new state (next_rem) if valid and not already visited
elif 0 < nextRem and nextRem not in visitedValues:
visitedValues.add(nextRem)
queue.append((nextRem, steps+1))
# All reachable remaining values processed without hitting 0,
# no combination of coins can make up the amount
# overall: tc O(amount * len(coins))
# overall: sc O(amount)
return -1Solution 2: [DP] Bottom Up Iterative Tabulation 0 to i - 1D Dynamic Programming/Iterative Tabulation (Direct N) Bottom Up
def coinChange(self, coins: List[int], amount: int) -> int:
# Note:
# Minimum coins to reach a target amount using Bottom Up Iterative Tabulation
# Coins:
# coins = [1, 2, 5], amount = 11
# DP Table:
# dp[n] = fewest coins needed to make amount n
# dp[0] = 0, dp[1] = 1, dp[2] = 1, ..., dp[11] = 3
# Sentinel:
# represents "not possible (yet)",
# so any real coin count will always be smaller and win the min() comparison
sentinel = float('inf')
# DP Array:
# dp[a] = min coins needed to make amount 'a'
# dp[0] = 0, since no coins needed to make amount 0
# sc: O(amount)
dp = [0] * (amount + 1)
# Iterate 1 to Amount:
# solve every subamount in increasing order
# tc: O(amount * len(coins))
for currTarget in range(1, amount + 1):
# local min
currMin = sentinel
# Explore Choices:
# try using each coin denomination as the next coin taken
for coin in coins:
# Early Prune:
# skip coins that would overshoot past 0
prevTarget = currTarget - coin
if 0 <= prevTarget:
# Since we are moving from 0..amount,
# dp[prevTarget] already stores its min coin count,
# So we would like to jump from that prevTarget to currTarget,
# so we need to add 1 coin for this jump
prevMin = dp[prevTarget] + 1
currMin = min(currMin, prevMin)
dp[currTarget] = currMin
# Min Coins:
# coins for amount was either set to sentinel during iteration,
# or we found a min num of coins
if dp[amount] != sentinel:
res = dp[amount]
else:
res = -1
# overall: tc O(amount * len(coins))
# overall: sc O(amount)
return res518. Coin Change II ::3:: - Medium
Topics: Array, Dynamic Programming, Knapsack Problem, Complete Knapsack
Intro
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.
| Example Input | Output |
|---|---|
| amount = 5, coins = [1,2,5] | 4 |
| amount = 3, coins = [2] | 0 |
| amount = 10, coins = [10] | 1 |
Constraints:
1 ≤ coins.length ≤ 300
1 ≤ coins[i] ≤ 5000
All the values of coins are unique.
0 ≤ amount ≤ 5000
Abstraction
Instead of the min number of coins to reach an amount, return to total number of combinations available to reach an amount
Pseudocode
oh! pseudocode hasn't been written yet, try another card! :)Solution 1: [DP] 0 to i Iterative Bottom Up Array - 2D Dynamic Programming/2D Dynamic Programming
def change(self, amount: int, coins: list[int]) -> int:
# DP Array:
# dp[target] = number of combinations that make target
# dp[0] = 1, since there is exactly 1 way to make 0 via choosing no coins
# sc: O(amount)
dp = [0] * (amount + 1)
dp[0] = 1
# Iterate Coins:
# process each coin one at a time so
# that each combination is counted only once,
# regardless of the order of its coins
# tc: O(amount * len(coins))
for coin in coins:
# Iterate Targets:
# start at coin because smaller targets cannot use this coin
# iterate forward so the current coin can be reused
for currTarget in range(coin, amount + 1):
# Jump to PrevTarget:
# coin allows us to connect CurrTarget to PrevTarget
prevTarget = currTarget - coin
# Multiple New Way To Reach CurrTarget:
# the number of ways we can reach prevTarget
# now connects to currTarget via the coin we just grabbed
dp[currTarget] += dp[prevTarget]
# Result:
# dp[amount] contains the total number of unique combinations
# overall: tc O(amount * len(coins))
# overall: sc O(amount)
return dp[amount]198. House Robber ::3:: - Medium
Topics: Array, Dynamic Programming
Intro
You are a professional robber planning to rob houses along a street. Each house has a certain amount of money stashed, the only constraint stopping you from robbing each of them is that adjacent houses have security systems connected and it will automatically contact the police if two adjacent houses were broken into on the same night. Given an integer array nums representing the amount of money of each house, return the maximum amount of money you can rob tonight without alerting the police.
| Example Input | Output |
|---|---|
| nums = [1,2,3,1] | 4 |
| nums = [2,7,9,3,1] | 12 |
Constraints:
1 ≤ nums.length ≤ 100
0 ≤ nums[i] ≤ 400
Abstraction
Given an array of cash, determine the max you can steal when you cannot steal from two adjacent entries.
Pseudocode
oh! pseudocode hasn't been written yet, try another card! :)Solution 1: [BFS] BFS Max Candidate Propagation Over Implicit Graph - 1D Dynamic Programming/DFS with Caching (Direct N) Top Down with Memoization
def rob(self, nums: List[int]) -> int:
# BFS Over The Implicit "House" Graph
# - each house index i is an implicit node, no literal graph
# is built up front
# - an edge connects house i to house i+1 (the SKIP option --
# if house i+1 isn't robbed, its best loot is at least
# whatever house i already achieved) and to house i+2 (the
# ROB option -- robbing house i+2 adds nums[i+2] on top of
# whatever house i achieved, since i+1 must be skipped)
# Note:
# Unlike Tribonacci's BFS (sum aggregation), this is a MAX
# aggregation -- each node's final value is the BEST of the
# candidates pushed into it, not the sum of them. Two edges can
# push a candidate into the same node (skip-candidate from i-1,
# rob-candidate from i-2), and the node takes whichever is
# larger. Since edges only ever point to LARGER indices, FIFO
# discovery order still coincides with topological order, so by
# the time a node is dequeued both of its candidates have
# already arrived.
# BFS State Encoding:
# - each queued item is simply a house index i
# - value[i] accumulates the MAX of all candidates pushed into
# it from predecessors i-1 (skip) and i-2 (rob), finalized
# before i itself is dequeued and propagated forward
# Legal Next Moves:
# - From house i, push a skip-candidate to i+1 and a
# rob-candidate to i+2, as long as neither overshoots n-1
# Discovery vs. Accumulation:
# - A house is enqueued only the FIRST time it's discovered,
# so it propagates its own (now-finalized) value forward
# exactly once
# - value[house] keeps taking the MAX of every candidate that
# arrives, even after it's enqueued -- this correctly
# resolves "rob or skip" at each house before it's read
# Safety Insight:
# because edges only ever point to LARGER indices, a house's
# predecessors (i-1, i-2) are always discovered and enqueued no
# later than the house itself, so by the time house i is
# dequeued, value[i] already holds its FINAL max and is safe to
# propagate forward
n = len(nums)
# Base Cases:
# house 0 has no predecessors -- its value is just its own loot
# house 1 has one real choice -- rob house 0 or house 1, whichever
# is bigger (no house -1 to rob-chain from)
if n == 1:
return nums[0]
# Value Array:
# value[i] = max loot achievable using houses[0..i]
# sc: O(n)
value = [0] * n
value[0] = nums[0]
value[1] = max(nums[0], nums[1])
# Iterative BFS Queue:
# - seed the queue with both base houses, since each
# independently starts propagating candidates forward
queue = deque([0, 1])
# Self Edges and Parallel Edges Safety Tracking:
# - mark both base houses as visited (discovered), so each
# only ever propagates forward once
# sc: O(n)
visited = {0, 1}
# tc: O(n), each house dequeued and expanded exactly once
while queue:
i = queue.popleft()
# Explore Neighbors:
# - push a skip-candidate to i+1
# - push a rob-candidate to i+2
for move in (1, 2):
j = i + move
# Legal Move Check:
# - skip any propagation that overshoots the last house
if j > n - 1:
continue
# Candidate Calculation:
# - move == 1: SKIP house j -> inherit value[i] as-is
# - move == 2: ROB house j -> value[i] + nums[j]
candidate = value[i] if move == 1 else value[i] + nums[j]
# Accumulate:
# - take the better of this candidate vs whatever
# value[j] already holds
# tc: O(1)
value[j] = max(value[j], candidate)
# Discovery Check:
# - only enqueue j the first time it's discovered, so
# it propagates its own finalized value forward
# exactly once
if j not in visited:
visited.add(j)
queue.append(j)
# Result:
# value[n-1] holds the max loot considering all n houses, having
# resolved every rob/skip candidate that ever arrived at it
res = value[n - 1]
# overall: tc O(n)
# overall: sc O(n)
return resSolution 2: [DP] i to N Recursive with Explicit Memoization Top Down - 1D Dynamic Programming/DFS with Caching (Direct N) Top Down with Memoization
def rob(self, nums: List[int]) -> int:
# Note:
# Top down recursive with memoization
# 0. Direct Length -> no need for len == 1 check, recursive padding boundary covers it
# 1. Memo Check -> computed previously
# 2. Direct Boundary -> 1st element
# 3. Direct Boundary -> 2nd element
# 3. Build From Previous -> grab from previous two steps and sum paths
# Result: max loop from first to last house
n = len(nums)
memo = {}
def dfs(i):
# Memo Check -> computed previously
if i in memo:
return memo[i]
# Direct Boundary -> 1st element
if i == 0:
return nums[0]
# Direct Boundary -> 2nd element
if i == 1:
return max(nums[0], nums[1])
# Direct Build From Previous -> grab from previous two steps and sum paths
# choose max between rob or skipping current house
memo[i] = max(nums[i] + dfs(i-2), dfs(i-1))
return memo[i]
# res -> max loot for nth house
res = dfs(n-1)
# overall: time complexity O(n)
# overall: space complexity O(n) (memo + recursion stack)
return resSolution 3: [DP] 0 to i Iterative Bottom Up Variables - 1D Dynamic Programming/Optimal Iterative (Direct N) Rolling State Variables Bottom Up
def rob(self, nums: List[int]) -> int:
# Note:
# Bottom Up Variables
# 0. Direct Length -> need for len == 1 check, 2nd element may not exist
# 1. Direct Variables -> 1st + 2nd element
# 2. Iterate -> 2 to n-1
# 3. Build From Previous -> grab from previous two steps and sum paths
# 4. Roll Variables -> Iterate Variables
# Result: max loot first to last house
n = len(nums)
# Direct Length -> need for len == 1 check, 2nd element may not exist
if n == 1:
return nums[0]
# Direct Variables -> 1st + 2nd element
prev2, prev1 = nums[0], max(nums[0], nums[1])
# Iterate -> 2 to n-1
for i in range(2, n):
# Build From Previous -> grab from previous two steps and sum paths
curr = max(prev1, nums[i] + prev2)
# Roll Variables -> Iterate Variables
prev2, prev1 = prev1, curr
# res -> max loot at nth house
res = prev1
# overall: time complexity O(n)
# overall: space complexity O(1)
return res213. House Robber II ::3:: - Medium
Topics: Array, Dynamic Programming
Intro
You are a professional robber planning to rob houses along a street. Each house has a certain amount of money stashed. All houses at this place are arranged in a circle. That means the first house is the neighbor of the last one. Meanwhile, adjacent houses have a security system connected, and it will automatically contact the police if two adjacent houses were broken into on the same night. Given an integer array nums representing the amount of money of each house, return the maximum amount of money you can rob tonight without alerting the police.
| Example Input | Output |
|---|---|
| nums = nums = [2,3,2] | 3 |
| nums = [1,2,3,1] | 4 |
| nums = [1,2,3] | 3 |
Constraints:
1 ≤ nums.length ≤ 100
0 ≤ nums[i] ≤ 1000
Abstraction
Given an array of cash, determine the max you can steal when you cannot steal from two adjacent entries, when array is circular.
Pseudocode
oh! pseudocode hasn't been written yet, try another card! :)Solution 1: [BFS] BFS Max Candidate Propagation Over Two Linear Sub Ranges - 1D Dynamic Programming/DFS with Caching (Direct N) Top Down with Memoization
def rob(self, nums: List[int]) -> int:
# BFS Over The Implicit "House" Graph, Run Twice
# - House Robber II reduces to House Robber I run on two
# separate linear ranges: houses [0, n-2] (excluding the
# last house) and houses [1, n-1] (excluding the first
# house). This breaks the circular adjacency between house 0
# and house n-1 -- at most one of those two ranges can
# include either "wraparound" house, so taking the max of
# both linear results always respects the circular
# constraint.
# Note:
# Same BFS shape as House Robber I: each house index i is a node
# with an edge to i+1 (skip-candidate: house i+1 inherits value[i]
# as-is) and to i+2 (rob-candidate: value[i] + nums[i+2]). Since
# edges only point to larger indices, FIFO discovery order still
# matches topological order, so by the time a node is dequeued,
# both incoming candidates (from i-1 and i-2) have already
# arrived and been max'd together.
# BFS State Encoding:
# - each queued item is simply a house index i (relative to
# the current sub-range being solved)
# - value[i] accumulates the MAX of all candidates pushed into
# it from predecessors i-1 (skip) and i-2 (rob)
# Legal Next Moves:
# - From house i, push a skip-candidate to i+1 and a
# rob-candidate to i+2, as long as neither overshoots the
# end of the current sub-range
# Safety Insight:
# identical to House Robber I -- predecessors i-1 and i-2 are
# always discovered and enqueued no later than house i itself,
# so value[i] is fully finalized by the time i is dequeued
n = len(nums)
# Edge Case:
# a single house has no circular conflict to resolve
if n == 1:
return nums[0]
def rob_range_bfs(start, end):
# Sub-Range Length:
m = end - start + 1
# Direct Length -> single house in this sub-range
if m == 1:
return nums[start]
# Value Array (Relative Indexing):
# value[i] = max loot achievable using sub-range houses[0..i]
# sc: O(m)
value = [0] * m
value[0] = nums[start]
value[1] = max(nums[start], nums[start + 1])
# Iterative BFS Queue:
# - seed with both base positions in this sub-range
queue = deque([0, 1])
# sc: O(m)
visited = {0, 1}
# tc: O(m), each position dequeued and expanded exactly once
while queue:
i = queue.popleft()
for move in (1, 2):
j = i + move
# Legal Move Check:
# - skip any propagation past the sub-range's end
if j > m - 1:
continue
# Candidate Calculation:
# - move == 1: SKIP house j -> inherit value[i]
# - move == 2: ROB house j -> value[i] + nums[start+j]
candidate = value[i] if move == 1 else value[i] + nums[start + j]
# Accumulate:
# - take the better of this candidate vs whatever
# value[j] already holds
value[j] = max(value[j], candidate)
# Discovery Check:
# - only enqueue j the first time it's discovered
if j not in visited:
visited.add(j)
queue.append(j)
# Result: max loot at the last house in this sub-range
return value[m - 1]
# Handle circular constraint -> cannot rob both first and last,
# so solve each linear sub-range independently and take the best
res1 = rob_range_bfs(0, n - 2)
res2 = rob_range_bfs(1, n - 1)
res = max(res1, res2)
# overall: tc O(n) -- two BFS passes, each over O(n) houses,
# expanding at most 2 edges per node
# overall: sc O(n) -- value array, visited set, and queue for
# each BFS call
return resSolution 1: [DP] i to N Recursive with Memoization Top Down - 1D Dynamic Programming/DFS with Caching (Direct N) Top Down with Memoization
def rob(self, nums: List[int]) -> int:
# Note:
# Top down (recursive with memoization)
# 0. Direct Length -> need for len == 1 check, 2nd element may not exist
# 1. Memo Check -> computed previously
# 2. Direct Boundary -> 1st + 2nd element
# 3. Build From Previous -> grab from previous two steps and sum paths
# 4. Handle circular constraint -> cannot rob both first and last
# Result: max loot for circular house array
n = len(nums)
# Direct Length -> need for len == 1 check, 2nd element may not exist
if n == 1:
return nums[0]
def rob_range(start, end):
memo = {}
def dfs(i):
# Memo Check -> computed previously
if i in memo:
return memo[i]
# Direct Boundary -> i == 0 (in subarray)
if i == start:
return nums[i]
# Direct Boundary -> i == 1 (in subarray)
if i == start + 1:
return max(nums[i], nums[i - 1])
# Build From Previous -> grab from previous two steps and sum paths
memo[i] = max(nums[i] + dfs(i-2), dfs(i-1))
return memo[i]
# res -> max loot at last house in subarray
return dfs(end)
# Handle circular constraint -> cannot rob both first and last
res1 = rob_range(0, n-2)
res2 = rob_range(1, n-1)
# res -> max loot from circular houses
res = max(res1, res2)
# overall: time complexity O(n)
# overall: space complexity O(n) (memo + recursion stack)
return resSolution 2: [DP] 0 to i Iterative Bottom Up Variables - 1D Dynamic Programming/Optimal Iterative (Direct N) Rolling State Variables Bottom Up
def rob(self, nums: List[int]) -> int:
# Note:
# Bottom up variables
# 0. Direct Length -> need for len == 1 check, 2nd element may not exist
# 1. Direct Length (subarray) -> need for len == 1 check, 2nd element may not exist
# 2. Direct Variables -> 1st + 2nd element
# 3. Iterate -> 2 to numHouses-1
# 4. Build From Previous -> grab from previous two steps and sum paths
# 5. Roll Variables -> Iterate Variables
# 6. Handle circular constraint -> cannot rob both first and last
# Result: max loot from circular houses
n = len(nums)
# Direct Length -> need for len == 1 check, 2nd element may not exist
if n == 1:
return nums[0]
def rob_range(start, end):
# Direct Length -> need for len == 1 check, 2nd element may not exist
m = end - start + 1
if m == 1:
return nums[start]
# Direct Variables -> 1st + 2nd element
TwoPrev = nums[start]
OnePrev = max(nums[start], nums[start+1])
# Iterate -> 2 to m-1
for i in range(2, m):
j = start + i
# Build From Previous -> grab from previous two steps and sum paths
curr = max(nums[j] + TwoPrev, OnePrev)
# Roll Variables -> Iterate Variables
TwoPrev, OnePrev = OnePrev, curr
# res -> max loot at last house in subarray
res = OnePrev
return res
# Handle circular constraint -> cannot rob both first and last
res1 = rob_range(0, n-2)
res2 = rob_range(1, n-1)
# res -> max loot from circular houses
res = max(res1, res2)
# overall: time complexity O(n)
# overall: space complexity O(1)
return res647. Palindromic Substrings ::3:: - Medium
Topics: Two Pointers, String, Dynamic Programming
Intro
Given a string s, return the number of palindromic substrings in it. A string is a palindrome when it reads the same backward as forward. A substring is a contiguous sequence of characters within the string.
| Example Input | Output |
|---|---|
| s = "abc" | 3 |
| s = "aaa" | 6 |
Constraints:
1 ≤ s.length ≤ 1000
s consists of lowercase English letters.
Abstraction
Given a string, determine how many palindromic substrings exist.
Pseudocode
oh! pseudocode hasn't been written yet, try another card! :)Solution 1: [BFS] BFS Multi Source Expansion From All Centers - 1D Dynamic Programming/Linear Property Tracking
def countSubstrings(self, s: str) -> int:
# BFS Over The Implicit "Substring Range" Graph
# - each valid palindromic range (l, r) is an implicit node,
# no literal graph is built up front
# - an edge connects (l, r) to (l-1, r+1), since a palindrome
# can always be "grown" outward by one character on each
# side, PROVIDED those two new characters match
# Note:
# This is the "expand around center" technique (Solution 1),
# reframed as a genuine multi-source BFS: instead of expanding
# ONE center fully via a while loop before moving to the next,
# ALL centers are seeded into the queue simultaneously and
# expanded outward one step at a time, level by level. Level k
# corresponds to "radius k" outward from every center at once --
# a real structural match to BFS, not a forced one, since
# distance-from-center is naturally a BFS-style expansion.
# BFS State Encoding:
# - each queued item is a confirmed palindromic range (l, r)
# - no separate "value" needed here -- simply being enqueued
# already means this exact range has been confirmed as a
# palindrome, so every dequeue directly counts toward the
# answer
# Legal Next Moves:
# - From range (l, r), attempt to grow to (l-1, r+1), legal
# only if both indices stay in bounds AND s[l-1] == s[r+1]
# Uniqueness Insight (No Visited Set Needed):
# - every range (l, r) has AT MOST ONE possible parent: the
# range (l+1, r-1) it was grown from. Since l+r (the center
# point, doubled) is fixed across an entire expansion chain,
# no two different centers can ever produce the SAME (l, r)
# pair -- so unlike other BFS solutions in this set, no
# visited set or discovery-check is required at all
n = len(s)
# Count:
# every node ever enqueued represents one confirmed palindromic
# substring, so count is simply the total nodes processed
count = 0
# Iterative BFS Queue:
# - seed with every ODD-length center: (i, i), always a valid
# length-1 palindrome on its own
# - seed with every EVEN-length center: (i, i+1), valid only
# if the two adjacent characters already match
# sc: O(n), up to 2n-1 seeded centers
queue = deque()
for i in range(n):
# Odd center: single character is always a palindrome
queue.append((i, i))
# Even center: only seed if the pair already matches
if i + 1 < n and s[i] == s[i + 1]:
queue.append((i, i + 1))
# tc: O(n^2), every valid (l, r) palindromic range is enqueued
# and dequeued exactly once across the whole run
while queue:
l, r = queue.popleft()
# Every dequeue is a confirmed palindrome -- count it
count += 1
# Explore Neighbor:
# - attempt to grow this palindrome outward by one
# character on each side
newL, newR = l - 1, r + 1
# Legal Move Check:
# - stay in bounds AND the new outer characters must match
if newL >= 0 and newR < n and s[newL] == s[newR]:
queue.append((newL, newR))
# overall: tc O(n^2) -- worst case (all same character, e.g.
# "aaa...a"), every one of O(n) centers expands O(n) times
# overall: sc O(n) -- queue holds at most O(n) in-flight ranges
# at any single BFS level (no separate visited set needed)
return countSolution 2: [DP] Two Pointers Expand Around Center - 1D Dynamic Programming/Linear Property Tracking
def countSubstrings(self, s: str) -> int:
n = len(s)
count = 0
# helper: expand from center
def expand(l: int, r: int) -> int:
local_count = 0
while l >= 0 and r < n and s[l] == s[r]:
local_count += 1 # found a palindrome
l -= 1
r += 1
return local_count
# expand around all possible centers
for i in range(n):
count += expand(i, i) # odd-length palindromes
count += expand(i, i + 1) # even-length palindromes
return countSolution 3: [DP] Dynamic Programming - 1D Dynamic Programming/Linear Property Tracking
def countSubstrings(self, s: str) -> int:
n = len(s)
dp = [[False] * n for _ in range(n)]
count = 0
# single characters
for i in range(n):
dp[i][i] = True
count += 1
# substring lengths 2 -> n
for length in range(2, n + 1):
for i in range(n - length + 1):
j = i + length - 1
if s[i] == s[j]:
if length == 2 or dp[i + 1][j - 1]:
dp[i][j] = True
count += 1
return count91. Decode Ways ::3:: - Medium
Topics: String, Dynamic Programming
Intro
You have intercepted a secret message encoded as a string of numbers. The message is decoded via the following mapping: "1" -> 'A' "2" -> 'B' ... "25" -> 'Y' "26" -> 'Z' However, while decoding the message, you realize that there are many different ways you can decode the message because some codes are contained in other codes ("2" and "5" vs "25"). For example, "11106" can be decoded into: "AAJF" with the grouping (1, 1, 10, 6) "KJF" with the grouping (11, 10, 6) The grouping (1, 11, 06) is invalid because "06" is not a valid code (only "6" is valid). Note: there may be strings that are impossible to decode. Given a string s containing only digits, return the number of ways to decode it. If the entire string cannot be decoded in any valid way, return 0. The test cases are generated so that the answer fits in a 32-bit integer.
| Example Input | Output |
|---|---|
| s = "12" | 2 |
| s = "226" | 3 |
| s = "06" | 0 |
Constraints:
1 ≤ s.length ≤ 100
s contains only digits and may contain leading zero(s).
Abstraction
Given a string, determine how many ways there to decode the string.
Pseudocode
oh! pseudocode hasn't been written yet, try another card! :)Solution 1: [BFS] BFS Forward Propagation Over Implicit Decoding - 1D Dynamic Programming/DFS with Caching (Direct N) Top Down with Memoization
def numDecodings(self, s: str) -> int:
# BFS Over The Implicit "Decode Position" Graph
# - each position i (0 to n, where position i means "the first
# i characters have been fully decoded") is an implicit node
# - an edge connects position i to i+1 if s[i] is a valid
# single-digit decode (s[i] != '0')
# - an edge connects position i to i+2 if s[i:i+2] is a valid
# two-digit decode (10 <= value <= 26)
# - both edges represent "one way to consume the next group of
# digits," so the number of ways to REACH position i is the
# SUM of ways from every valid predecessor
# Note:
# Same shape as Climbing Stairs/Tribonacci's BFS: not a
# shortest-path search, but forward value AGGREGATION. Since
# every edge points to a strictly larger position, the graph is
# a DAG and FIFO discovery order coincides with topological
# order, so by the time a position is dequeued, every
# contribution into it has already been added.
# BFS State Encoding:
# - each queued item is simply a position i
# - ways[i] accumulates the total decode-ways to reach
# position i, summed from contributions of i-1 (single
# digit) and i-2 (double digit) before i itself is dequeued
# and propagated forward
# Legal Next Moves:
# - From position i, propagate to i+1 only if s[i] != '0'
# - From position i, propagate to i+2 only if s[i:i+2] forms a
# valid two-digit code (10-26), and i+1 < n (need 2 chars left)
# Discovery vs. Accumulation:
# - A position is enqueued only the FIRST time it's discovered,
# so its own outgoing edges are only ever expanded once
# - ways[position] keeps ACCUMULATING every time a
# contribution into it arrives, even after it's enqueued --
# this correctly sums both the single-digit and double-digit
# paths that can land on the same position
# Safety Insight:
# because edges only ever point to LARGER positions, a
# position's predecessors (i-1, i-2) are always discovered and
# enqueued no later than the position itself, so by the time
# position i is dequeued, ways[i] already holds its FINAL total
n = len(s)
# Edge Case:
# leading zero makes the entire string undecodable from the start
if s[0] == '0':
return 0
# Ways Array:
# ways[i] = number of ways to decode the first i characters
# ways[0] = 1 -- the empty prefix has exactly one (trivial) way
# sc: O(n)
ways = [0] * (n + 1)
ways[0] = 1
# Iterative BFS Queue:
# - seed with position 0, the empty prefix
queue = deque([0])
# Self Edges and Parallel Edges Safety Tracking:
# - mark starting position as visited (discovered)
# sc: O(n)
visited = {0}
# tc: O(n), each position dequeued and expanded at most twice
while queue:
i = queue.popleft()
# Move 1 - Single Digit:
# - consume one character, legal only if it's not '0'
if i < n and s[i] != '0':
j = i + 1
# tc: O(1)
ways[j] += ways[i]
if j not in visited:
visited.add(j)
queue.append(j)
# Move 2 - Double Digit:
# - consume two characters, legal only if they form a
# valid code between 10 and 26, and two characters
# actually remain
if i + 1 < n:
two_digit = int(s[i:i + 2])
if 10 <= two_digit <= 26:
j = i + 2
# tc: O(1)
ways[j] += ways[i]
if j not in visited:
visited.add(j)
queue.append(j)
# Result:
# ways[n] holds the total decode ways for the full string,
# accumulated across every valid single/double digit path that
# ever reached it
res = ways[n]
# overall: tc O(n) -- each of the n+1 positions is dequeued
# once, expanding at most 2 edges
# overall: sc O(n) -- ways array, visited set, and queue
return resSolution 2: [DP] i to N Recursive with Memoization Top Down - 1D Dynamic Programming/DFS with Caching (Direct N) Top Down with Memoization
def numDecodings(self, s: str) -> int:
# Note:
# Top Down DP (recursive with memoization)
# 0. Direct Length -> no need for len == 1 check, recursive padding boundary covers it
# 1. Memo Check -> computed previously
# 2. Direct Boundary -> 1st element: empty string
# 3. Direct Boundary -> 2nd element: first char
# 4. Build From Previous -> grab from previous two steps and sum paths
# Result: total decode ways from start (0) to end (n-1)
n = len(s)
memo = {}
def dfs(i):
# Memo Check -> computed previously
if i in memo:
return memo[i]
# Direct Boundary -> 1st element: empty string
if i < 0:
return 1
# Direct Boundary -> 2nd element: first char
if i == 0:
return 1 if s[0] != '0' else 0
count = 0
# Build From Previous -> grab from previous two steps and sum paths
# 'How many total ways can I decode up to here if I decide to treat
# the current group as a valid single or valid double digit?'
# Add corresponding counts from single: i-1 and double: i-2
# check previous char, if single is valid
if s[i] != '0':
count += dfs(i-1)
# check double via previous char, if double is valid
two_digit = int(s[i-1:i+1])
if 10 <= two_digit <= 26:
count += dfs(i-2)
memo[i] = count
return memo[i]
# res -> number of decode ways for nth
res = dfs(n-1)
# overall: time complexity
# overall: space complexity
return resSolution 3: [DP] 0 to i Iterative Bottom Up Variables - 1D Dynamic Programming/Optimal Iterative (Direct N) Rolling State Variables Bottom Up
def numDecodings(self, s: str) -> int:
# Note:
# Iteration here is unique here because we cant initialize/skip i = 1,
# because it introduces the first valid two digit choice.
# If we jumped directly to i = 2, we’d miss the case where the answer
# depends entirely on that first double digit decode.
# Note:
# Bottom-up DP with space optimization
# 0. Direct Length -> need for len == 0 check, 2nd element/first char, maybe not exist
# 1. Direct Variables -> 1st element
# 2. Iterate -> 1 to n-1
# 3. Build From Previous -> grab from previous two steps and sum paths
# 4. Roll Variables -> Iterate Variables
# Result -> total decode ways nth
# Direct Length -> need for len == 0 check, 2nd element/first char, maybe not exist
if not s:
return 0
n = len(s)
# Direct Variables -> 1st element
prev2 = 0
prev1 = 1 if s[0] != '0' else 0
# Iterate -> 1 to n-1
for i in range(1, n):
curr = 0
# Build From Previous -> grab from previous two steps and sum paths
# 'How many total ways can I decode up to here if I decide to treat
# the current group as a valid single or valid double digit?'
# Add corresponding counts from single: i-1 and double: i-2
# check previous char, if single is valid
if s[i] != '0':
curr += prev1
# decode ignoring double digit
two_digit = int(s[i-1:i+1])
if 10 <= two_digit <= 26:
curr += prev2 if i >= 2 else 1
# Roll Variables -> Iterate Variables
prev2, prev1 = prev1, curr
# res -> number of decode ways for nth
res = prev1
# overall: time complexity O(n)
# overall: space complexity O(1)
return res152. Maximum Product Subarray ::2:: - Medium
Topics: Array, Dynamic Programming
Intro
Given an integer array nums, find a subarray that has the largest product, and return the product. The test cases are generated so that the answer will fit in a 32-bit integer.
| Example Input | Output |
|---|---|
| nums = [2,3,-2,4] | 6 |
| nums = [-2,0,-1] | 0 |
Constraints:
1 ≤ nums.length ≤ 2 * 104
-10 ≤ nums[i] ≤ 10
The product of any subarray of nums is guaranteed to fit in a 32-bit integer.
Abstraction
Given a array, find the subarray with the largest product and return the product.
Pseudocode
oh! pseudocode hasn't been written yet, try another card! :)Solution 1: [DP] BFS Forward Propagation Of Max Min Product Pairs - 1D Dynamic Programming/DFS with Caching (Direct N) Top Down with Memoization
def numDecodings(self, s: str) -> int:
# BFS Over The Implicit "Decode Position" Graph
# - each position i (0 to n, where position i means "the first
# i characters have been fully decoded") is an implicit node
# - an edge connects position i to i+1 if s[i] is a valid
# single-digit decode (s[i] != '0')
# - an edge connects position i to i+2 if s[i:i+2] is a valid
# two-digit decode (10 <= value <= 26)
# - both edges represent "one way to consume the next group of
# digits," so the number of ways to REACH position i is the
# SUM of ways from every valid predecessor
# Note:
# Same shape as Climbing Stairs/Tribonacci's BFS: not a
# shortest-path search, but forward value AGGREGATION. Since
# every edge points to a strictly larger position, the graph is
# a DAG and FIFO discovery order coincides with topological
# order, so by the time a position is dequeued, every
# contribution into it has already been added.
# BFS State Encoding:
# - each queued item is simply a position i
# - ways[i] accumulates the total decode-ways to reach
# position i, summed from contributions of i-1 (single
# digit) and i-2 (double digit) before i itself is dequeued
# and propagated forward
# Legal Next Moves:
# - From position i, propagate to i+1 only if s[i] != '0'
# - From position i, propagate to i+2 only if s[i:i+2] forms a
# valid two-digit code (10-26), and i+1 < n (need 2 chars left)
# Discovery vs. Accumulation:
# - A position is enqueued only the FIRST time it's discovered,
# so its own outgoing edges are only ever expanded once
# - ways[position] keeps ACCUMULATING every time a
# contribution into it arrives, even after it's enqueued --
# this correctly sums both the single-digit and double-digit
# paths that can land on the same position
# Safety Insight:
# because edges only ever point to LARGER positions, a
# position's predecessors (i-1, i-2) are always discovered and
# enqueued no later than the position itself, so by the time
# position i is dequeued, ways[i] already holds its FINAL total
n = len(s)
# Edge Case:
# leading zero makes the entire string undecodable from the start
if s[0] == '0':
return 0
# Ways Array:
# ways[i] = number of ways to decode the first i characters
# ways[0] = 1 -- the empty prefix has exactly one (trivial) way
# sc: O(n)
ways = [0] * (n + 1)
ways[0] = 1
# Iterative BFS Queue:
# - seed with position 0, the empty prefix
queue = deque([0])
# Self Edges and Parallel Edges Safety Tracking:
# - mark starting position as visited (discovered)
# sc: O(n)
visited = {0}
# tc: O(n), each position dequeued and expanded at most twice
while queue:
i = queue.popleft()
# Move 1 - Single Digit:
# - consume one character, legal only if it's not '0'
if i < n and s[i] != '0':
j = i + 1
# tc: O(1)
ways[j] += ways[i]
if j not in visited:
visited.add(j)
queue.append(j)
# Move 2 - Double Digit:
# - consume two characters, legal only if they form a
# valid code between 10 and 26, and two characters
# actually remain
if i + 1 < n:
two_digit = int(s[i:i + 2])
if 10 <= two_digit <= 26:
j = i + 2
# tc: O(1)
ways[j] += ways[i]
if j not in visited:
visited.add(j)
queue.append(j)
# Result:
# ways[n] holds the total decode ways for the full string,
# accumulated across every valid single/double digit path that
# ever reached it
res = ways[n]
# overall: tc O(n) -- each of the n+1 positions is dequeued
# once, expanding at most 2 edges
# overall: sc O(n) -- ways array, visited set, and queue
return resSolution 2: [DP] Modified Kadane i to N Recursive with Memoization Top Down - 1D Dynamic Programming/DFS with Caching (Direct N) Top Down with Memoization
def maxProduct(self, nums: List[int]) -> int:
# Note:
# Top Down (recursive with memoization)
# 0. Direct Length -> empty check
# 1. Memo Check -> computed previously
# 1. Direct Boundary -> 1st element
# 2. Build From Previous -> grab previous max/min and calculate new max/min at i
# 3. Three Possibilities -> infer max subarray
# Result -> track overall maximum from all dfs calls
n = len(nums)
# Direct Length -> empty array check
if n == 0:
return 0
memo = {}
def dfs(i):
# Memo Check -> computed previously
if i in memo:
return memo[i]
# Direct Boundary -> 1st element
if i == 0:
memo[0] = (nums[0], nums[0])
return memo[0]
# Build From Previous -> grab previous max/min and calculate new max/min at i
prev_max, prev_min = dfs(i-1)
# three possibilities
# 1. start new subarray at i -> num
# 2. extend previous max subarray -> num * prev_max
# 3. extend previous min subarray -> num * prev_min
num = nums[i]
curr_max = max(num, num * prev_max, num * prev_min)
curr_min = min(num, num * prev_max, num * prev_min)
# res -> max decision/subarray up to index i
memo[i] = (curr_max, curr_min)
return memo[i]
# start with max subarray at 0
result = nums[0]
for i in range(n):
# max subarray at i
curr_max, curr_min = dfs(i)
# res -> overall max subarray
result = max(result, curr_max)
# overall: time complexity O(n)
# overall: space complexity O(n)
return resultSolution 3: [DP] Modified Kadane 0 to i Iterative Bottom Up Variables - 1D Dynamic Programming/Optimal Iterative (Direct N) Rolling State Variables Bottom Up
def maxProduct(self, nums: List[int]) -> int:
# Note:
# Bottom Up DP (rolling variables)
# 0. Direct Length -> empty array check
# 1. Direct Variables -> initialize max_dp, min_dp at index 0
# 2. Iterate -> 1 to n-1
# 3. Build From Previous -> grab previous max/min and calculate new max/min at i
# 4. Three Possibilities -> infer max subarray
# Result -> grab overall max subarray
n = len(nums)
# Direct Length -> empty array check
if n == 0:
return 0
# Direct Variables -> initialize max_dp, min_dp at index 0
max_prod = min_prod = result = nums[0]
# Iterate -> 1 to n-1
for i in range(1, n):
# Store previous rolling values
prev_max, prev_min = max_prod, min_prod
# Build From Previous -> grab previous max/min and calculate new max/min at i
num = nums[i]
max_prod = max(num, prev_max * num, prev_min * num)
min_prod = min(num, prev_max * num, prev_min * num)
# Update overall result
result = max(result, max_prod)
# overall: time complexity O(n)
# overall: space complexity O(1)
return result139. Word Break ::4:: - Medium
Topics: Array, Hash Table, String, Dynamic Programming, Trie, Memoization
Intro
Given a string s and a dictionary of strings wordDict, return true if s can be segmented into a space separated sequence of one or more dictionary words. Note that the same word in the dictionary may be reused multiple times in the segmentation.
| Example Input | Output |
|---|---|
| s = "leetcode", wordDict = ["leet","code"] | true |
| s = "applepenapple", wordDict = ["apple","pen"] | true |
| s = "catsandog", wordDict = ["cats","dog","sand","and","cat"] | false |
Constraints:
1 ≤ s.length ≤ 300
1 ≤ wordDict.length ≤ 1000
1 ≤ wordDict[i].length ≤ 20
s and wordDict[i] consist of only lowercase English letters.
All the strings of wordDict are unique.
Abstraction
Given a string and an array of strings, determine if the string can be separated into some combination of strings from the array.
Pseudocode
oh! pseudocode hasn't been written yet, try another card! :)Solution 1: [BFS] BFS Over Implicit Segmentation Point Graph - 1D Dynamic Programming/DFS with Caching (Direct N) Top Down with Memoization
def wordBreak(self, s: str, wordDict: List[str]) -> bool:
# BFS Over The Implicit "Segmentation Point" Graph
# - each index i (0 to n, where i means "the first i
# characters have been validly segmented") is an implicit
# node, no literal graph is built up front
# - an edge connects index i to index j (j > i) if s[i:j] is a
# word in the dictionary -- taking that edge represents
# "consume this dictionary word next"
# Note:
# This is a genuine REACHABILITY question -- "can we reach node
# n starting from node 0" -- which is exactly what BFS is built
# for, unlike the forced aggregation-style BFS solutions
# elsewhere in this set (Climbing Stairs, Tribonacci, Decode
# Ways). There's no sum/max to accumulate here, just "is n
# reachable at all," so the first time n is dequeued, we can
# return True immediately -- true shortest-path-style early exit.
# BFS State Encoding:
# - each queued item is simply an index i, representing "the
# prefix s[0:i] has been confirmed segmentable"
# Legal Next Moves:
# - From index i, try every dictionary word w; if
# s[i:i+len(w)] == w, index i+len(w) becomes reachable
# Self Edges and Parallel Edges Safety:
# - Once an index has been visited, we know it's already
# queued for expansion, so re-discovering it via a different
# word/path adds no new information -- safe to skip requeuing
n = len(s)
# Edge Case:
# empty string requires no words at all
if n == 0:
return True
# Dictionary lookup:
# sc: O(m), m = number of words in wordDict
word_set = set(wordDict)
# Iterative BFS Queue:
# - seed with index 0, the empty prefix, always reachable
queue = deque([0])
# Self Edges and Parallel Edges Safety Tracking:
# - mark starting index as visited
# sc: O(n)
visited = {0}
# tc: O(n^2) worst case -- each of the n indices dequeued once,
# trying every possible substring length up to n
while queue:
i = queue.popleft()
# Explore Neighbors:
# - try every possible end point j > i, checking if the
# substring s[i:j] is a valid dictionary word
for j in range(i + 1, n + 1):
# Legal Move Check:
# - skip already-visited indices, no new info gained
if j in visited:
continue
# tc: O(j - i) for slicing/hashing the substring
if s[i:j] in word_set:
# Check:
# - reaching the full string length means a
# complete valid segmentation was found
if j == n:
return True
visited.add(j)
queue.append(j)
# Queue exhausted without ever reaching n -- no valid
# segmentation exists
# overall: tc O(n^2) -- to O(n^3) if substring slicing/hashing
# cost is counted per comparison (each slice up to O(n))
# overall: sc O(n + m) -- visited set, queue, and word_set
return FalseSolution 2: [DP] i to N Recursive with Memoization Top Down - 1D Dynamic Programming/DFS with Caching (Direct N) Top Down with Memoization
def wordBreak(self, s: str, wordDict: List[str]) -> bool:
# Note:
# Top Down DP (recursive with memoization)
# 0. Direct Length -> empty string boundary covered by recursion
# 1. Memo Check -> computed previously
# 2. Direct Boundary -> empty string is valid segmentation
# 2. Iterate -> 0 to i, try every partition ending at i
# 3. Build From Previous -> if s[j:i] in dict and dfs(j) is True
# 4. Backtrack -> store False if no valid segmentation
# Result: can full string be segmented
n = len(s)
# int -> bool
memo = {}
# lookup
word_set = set(wordDict)
def dfs(i) -> bool:
# Direct Boundary -> empty string is valid segmentation
if i == 0:
return True
# Memo Check -> computed previously
if i in memo:
return memo[i]
# Iterate -> 0 to i, try every partition ending at i
for j in range(i):
# Build From Previous -> valid segmentation
# check if s[j:i] is valid dictionary word
# checks if remaining substring before segment (start to j) can be segmented
if s[j:i] in word_set and dfs(j):
memo[i] = True
return True
# Backtrack -> no valid segmentation
memo[i] = False
return False
# res -> can segment full string
res = dfs(n)
# overall: time complexity O(n^2)
# overall: space complexity O(n)
return resSolution 3: [DP] 0 to i Iterative Bottom Up Rolling Variables Sliding Window - 1D Dynamic Programming/Sequential Segment Choice Validation
def wordBreak(self, s: str, wordDict: List[str]) -> bool:
# Note:
# Bottom Up DP with rolling variables (sliding window)
# 0. Direct Length -> empty string check
# 1. Window Size -> track last max_word_length states only
# 2. Initialize -> dp[0] = True for empty string
# 3. Iterate -> 1 to n
# 4. Build From Previous -> check previous positions within window
# 5. Early stop -> break if valid segmentation found
# Result -> dp[n % (max_len + 1)] gives final result
n = len(s)
if n == 0:
return True
word_set = set(wordDict)
max_len = max(map(len, wordDict)) if wordDict else 0
# Initialize DP rolling window
dp = [False] * (max_len + 1)
dp[0] = True # empty string
# Iterate over string positions
for i in range(1, n + 1):
dp[i % (max_len + 1)] = False
# Build From Previous -> look back up to max_len
for l in range(1, min(i, max_len) + 1):
if dp[(i - l) % (max_len + 1)] and s[i - l:i] in word_set:
dp[i % (max_len + 1)] = True
break # early stop, found valid segmentation
# Result -> can full string be segmented
res = dp[n % (max_len + 1)]
# overall: time complexity
# overall: space complexity
return res300. Longest Increasing Subsequence ::3:: - Medium
Topics: Array, Binary Search, Dynamic Programming
Intro
Given an integer array nums, return the length of the longest strictly increasing subsequence. Follow up: Can you come up with an algorithm that runs in O(n log(n)) time complexity?
| Example Input | Output |
|---|---|
| nums = [10,9,2,5,3,7,101,18] | 4 |
| nums = [0,1,0,3,2,3] | 4 |
| nums = [7,7,7,7,7,7,7] | 1 |
Constraints:
1 ≤ nums.length ≤ 2500
-104 ≤ nums[i] ≤ 104
Abstraction
Given an integer array, find the longest increasing subsequence.
Pseudocode
oh! pseudocode hasn't been written yet, try another card! :)Solution 1: [BFS] BFS Longest Path Relaxation Over Implicit Graph - 1D Dynamic Programming/Subsequence Optimization Constrained
def lengthOfLIS(self, nums: List[int]) -> int:
# Note:
# Top Down DP (recursive with memoization)
# 0. Direct Length -> empty array boundary covered by recursion
# 1. Memo Check -> computed previously
# 2. Direct Boundary -> single element subsequence has length 1
# 3. Explore Choices -> check all j > i to extend subsequence
# 4. Build From Previous -> max length using future extensions
# Result -> maximum LIS starting from index 0 to n-1
n = len(nums)
memo = {}
def dfs(i):
# Memo Check -> computed previously
if i in memo:
return memo[i]
# Direct Boundary -> single element
max_len = 1
# Explore Choices -> try to extend from i to future indices
for j in range(i + 1, n):
if nums[j] > nums[i]:
max_len = max(max_len, 1 + dfs(j))
memo[i] = max_len
return max_len
# Result -> try starting from every index
res = max(dfs(i) for i in range(n))
# overall: time complexity
# overall: space complexity
return resSolution 2: Dynamic Programming - 1D Dynamic Programming/Subsequence Optimization Constrained
def lengthOfLIS(self, nums: List[int]) -> int:
# Note:
# Top Down DP (recursive with memoization)
# 0. Direct Length -> empty array boundary covered by recursion
# 1. Memo Check -> computed previously
# 2. Direct Boundary -> single element subsequence has length 1
# 3. Explore Choices -> check all j > i to extend subsequence
# 4. Build From Previous -> max length using future extensions
# Result -> maximum LIS starting from index 0 to n-1
n = len(nums)
memo = {}
def dfs(i):
# Memo Check -> computed previously
if i in memo:
return memo[i]
# Direct Boundary -> single element
max_len = 1
# Explore Choices -> try to extend from i to future indices
for j in range(i + 1, n):
if nums[j] > nums[i]:
max_len = max(max_len, 1 + dfs(j))
memo[i] = max_len
return max_len
# Result -> try starting from every index
res = max(dfs(i) for i in range(n))
# overall: time complexity
# overall: space complexity
return resSolution 3: Binary Search - 1D Dynamic Programming/Subsequence Optimization Constrained
def lengthOfLIS(self, nums: List[int]) -> int:
# Note:
# Same as Solution 2, but manual binary search instead of bisect
# 1. Process root -> current number
# 2. Explore choices -> search for first tail >= num
# 3. Build -> append if end, replace otherwise
# 4. Result -> length of tails = LIS length
tails = []
#
for num in nums:
left, right = 0, len(tails) - 1
# Explore Choices -> find insertion/replacement index
while left <= right:
#
mid = (left + right) // 2
#
if tails[mid] < num:
left = mid + 1
else:
right = mid - 1
# Build: insert or replace
if left == len(tails):
tails.append(num)
else:
tails[left] = num
return len(tails)416. Partition Equal Subset Sum ::2:: - Medium
Topics: Array, Dynamic Programming
Intro
Given an integer array nums, return true if you can partition the array into two subsets such that the sum of the elements in both subsets is equal or false otherwise.
| Example Input | Output |
|---|---|
| nums = [1,5,11,5] | true |
| nums = [1,2,3,5] | false |
Constraints:
1 ≤ nums.length ≤ 200
1 ≤ nums[i] ≤ 100
Abstraction
Given an integer array, determine if you can split it into two arrays such that the sum of each arrays is equal.
Pseudocode
oh! pseudocode hasn't been written yet, try another card! :)Solution 1: [BFS] BFS Over Implicit Reachable Sum Graph - 1D Dynamic Programming/Subset Sum Linear Choice Selection
def canPartition(self, nums: List[int]) -> bool:
# BFS Over The Implicit "Achievable Sum" Graph
# - each achievable running sum s (0 to target) is an implicit
# node, no literal graph is built up front
# - processing numbers one at a time, an edge connects sum s
# to sum s + num for the CURRENT number being considered --
# taking that edge means "include this number in the subset"
# Note:
# Same shape as Word Break's BFS -- a genuine REACHABILITY
# question ("can we reach sum == target using some subset of
# nums"), which is exactly what BFS is built for. The twist here
# is that edges are grouped by NUMBER, not by source node: all
# currently-reachable sums must attempt their edge for the
# CURRENT number before any of them are allowed to use the NEXT
# number's edge -- otherwise the same number could be reused
# multiple times in one subset, which isn't allowed (this is the
# same "iterate target downward" safeguard from Solution 1,
# reframed as "process one full BFS level per number" instead).
# BFS State Encoding:
# - each queued item is simply an achievable sum s
# - visited tracks every sum ever confirmed reachable across
# all numbers processed so far
# Legal Next Move:
# - From sum s, while processing number num, move to s + num,
# as long as it doesn't exceed target
# Self Edges and Parallel Edges Safety:
# - Once a sum has been visited, re-reaching it via a
# different combination of numbers adds no new information,
# so it's safe to skip requeuing
total = sum(nums)
# Odd total can never split into two equal integer halves
if total % 2 != 0:
return False
target = total // 2
# Edge Case:
# sum 0 is always trivially achievable (the empty subset)
if target == 0:
return True
# Iterative BFS Queue:
# - seed with sum 0, achievable before considering any numbers
# sc: O(target)
queue = deque([0])
# Self Edges and Parallel Edges Safety Tracking:
# - mark starting sum as visited
# sc: O(target)
visited = {0}
# tc: O(n * target), one full BFS "round" per number, each
# round processing at most O(target) currently-reachable sums
for num in nums:
# Level Boundary Per Number:
# - drain exactly the sums that were reachable BEFORE this
# number was considered, so num is never used twice in
# the same subset (mirrors Solution 1's reverse-order
# iteration safeguard)
for _ in range(len(queue)):
s = queue.popleft()
# Re-enqueue the sum itself unchanged (the "skip this
# number" choice), so it's still available for future
# numbers' rounds
queue.append(s)
newSum = s + num
# Legal Move Check:
# - skip if it overshoots the target
if newSum > target:
continue
# Check:
# - reaching exactly target means a valid partition
# exists
if newSum == target:
return True
# Discovery Check:
# - only enqueue newSum the first time it's discovered
if newSum not in visited:
visited.add(newSum)
queue.append(newSum)
# Queue exhausted (all numbers processed) without ever reaching
# target -- no valid partition exists
# overall: tc O(n * target) -- n numbers, each round processing
# up to O(target) reachable sums
# overall: sc O(target) -- visited set and queue
return FalseSolution 2: [DP] Dynamic Programming Subset Sum - 1D Dynamic Programming/Subset Sum Linear Choice Selection
def canPartition(self, nums: List[int]) -> bool:
# Note:
# 1D DP for subset sum
# 1. Process root -> current number in nums
# 2. Explore choices -> include current number or skip it
# 3. Build -> update achievable sums in dp
# 4. Result -> check if target sum is achievable
total = sum(nums)
# If total sum is odd, cannot partition equally
if total % 2 != 0:
return False
target = total // 2
n = len(nums)
# dp[i] = True if sum i is achievable with some subset
dp = [False] * (target + 1)
# sum 0 is always achievable (root/base case)
dp[0] = True
for num in nums:
# Explore choices in reverse to avoid using same number twice
for i in range(target, num - 1, -1):
# Build: include num if sum i-num was achievable
dp[i] = dp[i] or dp[i - num]
# Result: can we achieve target sum?
return dp[target]Solution 3: [DP] Bitmask Bitset DP - 1D Dynamic Programming/Subset Sum Linear Choice Selection
def canPartition(self, nums: List[int]) -> bool:
# Note:
# Bitmask DP (bitset)
# 1. Process root -> each number
# 2. Explore choices -> include or skip number, track sums as bits
# 3. Build -> shift bits to represent new achievable sums
# 4. Result -> check if target sum bit is set
total = sum(nums)
if total % 2 != 0:
return False
target = total // 2
# bitset where i-th bit = True if sum i achievable
bits = 1 # only 0 sum is achievable initially
for num in nums:
# Build: shift current bits by num to represent including it
bits |= bits << num
# Result: check if target sum is achievable
return (bits >> target) & 1 == 1