Jc-alt logo
jc
data structures and algorithms

1D Dynamic Programming

1D Dynamic Programming
79 min read
#data structures and algorithms
Table Of Contents

1D Dynamic Programming Intro

LeetCode problems solved with dynamic programming

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.

  1. dfs(i) calls dfs(i+1) and dfs(i+2).
  2. dfs(i+1) recomputes dfs(i+2), with its own dfs(i+1) -> dfs(i+1+1)
  3. 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.

  1. 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)

  1. 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.

  1. 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.

  1. 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 iteratively

DP 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.

  1. 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]

  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 prev1

1D 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 prev1

1D 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 res

1D 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 -1

70. Climbing Stairs ::4:: - 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 InputOutput
n = 22
n = 33

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.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

Solution 1: i to N Recursive with Memoization Top Down - 1D Dynamic Programming/DFS with Caching (Padded N+1) Top Down with Memoization

    def climbStairs(self, n: int) -> int:

        # Note:
        # Top Down DP (memoized recursion)
        # 0. Direct Length -> no need for len == n check, recursive padding boundary covers it
        # 1. Memo Check -> computed previously
        # 2. Pad Boundaries -> overshot case case
        # 3. Padded Boundary -> last element n
        # 3. Build From Previous -> grab from previous two steps and sum paths
        # 5. Memo Return -> return ways, calculated once
        # Result: Ways to climb from 0 to nth step

        memo = {}

        def dfs(i):

            # Memo Check -> computed previously
            if i in memo:
                return memo[i]
            
            # Pad Boundaries -> overshot case case
            if i > n:
                return 0

            # Padded Boundary -> last element n
            if i == n:
                return 1

            # Build From Previous -> grab from previous two steps and sum paths
            memo[i] = dfs(i + 1) + dfs(i + 2)

            # Memo return: return ways, calculated once
            return memo[i]

        # res -> ways to get from 0 to nth step
        res = dfs(0)

        # overall: time complexity O(n)
        # overall: space complexity O(n) (memo + recursion stack)
        return res

Solution 2: 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:

        # Note:
        # Top Down DP (memoized recursion)
        # 0. Direct Length -> no need for len == 1 check, recursive padding boundary covers it
        # 1. Memo Check -> computed previously
        # 2. Pad Boundaries -> 0 ground case
        # 3. Padded Boundary -> 1st element
        # 4. Build From Previous -> grab from previous two steps and sum paths
        # 5. Memo return -> return ways, calculated once
        # Result: Ways to climb from 0 to nth step

        # Ways to climb from 0 to ith step
        memo = {}

        def dfs(i):

            # Memo Check -> computed previously
            if i in memo:
                return memo[i]
            
            # Pad Boundaries -> 0 ground case
            if i == 0:
                return 1

            # Padded Boundary -> 1st element
            if i == 1:
                return 1

            # Build From Previous -> grab from previous two steps and sum paths
            memo[i] = dfs(i - 1) + dfs(i - 2)

            # Memo return -> return ways, calculated once
            return memo[i]

        # res -> ways to climb from 0 to nth step
        res = dfs(n)

        # overall: time complexity O(n)
        # overall: space complexity O(n) (memo + recursion stack)
        return res

Solution 3: 0 to i Iterative Bottom Up Array - 1D Dynamic Programming/Iterative Tabulation (Padded N+1) Bottom Up

    def climbStairs(self, n: int) -> int:
        
        # Note:
        # Iterative Bottom Up Tabulation
        # 1. Pad Array -> 0 ground case, dp size (N+1)
        # 2. Padded -> grab 1st element
        # 3. Iterate -> 2 to n
        # 4. Build From Previous -> grab from previous two steps and sum paths
        # Result: ways to climb to nth step

        # Pad Array -> 0 ground case, dp size (N+1)
        dp = [0] * (n + 1)   # padding:
        dp[0] = 1            # padding: ground case, does not exist in array
        dp[1] = 1            # padded: first step

        # Iterate -> 2 to nth
        for i in range(2, n+1):
            # Build From Previous -> grab from previous two steps and sum paths
            dp[i] = dp[i-1] + dp[i-2]

        # res -> nth step
        res = dp[n]

        # overall: time complexity O(n)
        # overall: space complexity O(n)
        return res

Solution 4: 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:
        
        # Note:
        # Iterative Bottom Up Variables
        # 1. Pad Variables -> 0 ground case
        # 2. Padded -> grab 2st element
        # 3. Iterate -> 2 to n
        # 4. Build From Previous -> grab from previous two steps and sum paths
        # 5. Roll Variables -> Iterate Variables
        # Result: ways to climb to nth step

        # Pad Variables -> 0 ground case
        TwoPrev = 1  # padding:
        OnePrev = 1  # padded: 1st element

        # Iterate -> 2 to n
        for i in range(2, n+1):

            # Build From Previous -> grab from previous two steps and sum paths
            curr = TwoPrev + OnePrev

            # Slide Window -> Roll Variables
            TwoPrev, OnePrev = OnePrev, curr
        
        # res -> ways to climb to n step
        res = OnePrev

        # overall: time complexity O(n)
        # overall: space complexity O(1)
        return res

746. Min Cost Climbing Stairs ::4:: - 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 InputOutput
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.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

Solution 1: i to N Recursive with Explicit Memoization Top Down - 1D Dynamic Programming/DFS with Caching (Padded N+1) 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 padding boundary covers it
        # 1. Memo Check -> computed previously
        # 2. Padding Boundary -> ground step 0
        # 3. Padded Boundary -> first element
        # 4. 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]

            # Padding Boundary -> ground step 0
            if i == 0:
                return 0

            # Padded Boundary -> first element
            if i == 1:
                return cost[0]

            # Build From Previous -> min cost to reach current step
            memo[i] = cost[i-1] + min(dfs(i-1), dfs(i-2))
            return memo[i]

        # res -> reach step n by coming from last two steps
        res = min(dfs(n), dfs(n-1))

        # overall: time complexity O(n)
        # overall: space complexity O(n) (memo + recursive stack)
        return res

Solution 2: 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 res

Solution 3: 0 to i Iterative Bottom Up Array - 1D Dynamic Programming/Iterative Tabulation (Direct N) Bottom Up

    def minCostClimbingStairs(self, cost: List[int]) -> int:
        
        # Note:
        # Bottom Up Tabulation
        # 0. Direct Length -> no need for len == 1 check, description says min length == 2
        # 1. Direct Array -> 1st + 2nd element
        # 3. Iterate -> 2 to n-1
        # 4. Build From Previous -> grab from previous two steps and sum paths
        # Result: min cost to reach n via continuing from step n-1 or n-2

        # 0. Direct Length -> no need for len == 1 check, description says min length == 2

        n = len(cost)

        # Direct Array -> 1st + 2nd element
        dp = [0] * n
        dp[0], dp[1] = cost[0], cost[1]

        # Iterate -> steps 2 to n-1
        for i in range(2, n):

            # Build From Previous -> grab from previous two steps and sum paths
            dp[i] = cost[i] + min(dp[i-1], dp[i-2])

        # res -> min cost to reach n
        res = min(dp[n-1], dp[n-2])

        # overall: time complexity O(n)
        # overall: space complexity O(n)
        return res

Solution 4: 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 res

1137. 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 InputOutput
n = 44
n = 251389537

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.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

Solution 1: 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 res

Solution 2: 0 to i Iterative Bottom Up Array - 1D Dynamic Programming/Iterative Tabulation (Direct N) Bottom Up

    def tribonacci(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 Tabulation
        # 1. Direct Boundary -> need for len == 0, len == 1, and len == 2, as 3rd element may not exist
        # 2. Direct Array -> 1st, 2nd, and 3rd element
        # 3. Iterate -> 2 to n-1
        # 4. Iterate -> 3 to n
        # 5. Build From Previous -> sum previous three DP entries
        # Result: T_n in Tribonacci sequence

        # Direct Boundary -> len == 0, len == 1, and len == 2, as 3rd element may not exist
        if n == 0:
            return 0
        if n == 1:
            return 1

        # Direct Array -> 1st, 2nd, and 3rd element
        dp = [0] * (n+1)
        dp[0], dp[1], dp[2] = 0, 1, 1

        # Iterate -> 3 to n
        for i in range(3, n+1):

            # Build From Previous -> sum of last three
            dp[i] = dp[i-1] + dp[i-2] + dp[i-3]

        # res -> nth tribonacci
        res = dp[n]

        # overall: time complexity O(n)
        # overall: space complexity O(n) (memo + recursive stack)
        return res

Solution 3: 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 res

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 InputOutput
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.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

Solution 1: 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 res

Solution 2: 0 to i Iterative Bottom Up Array - 1D Dynamic Programming/Iterative Tabulation (Direct N) Bottom Up

    def rob(self, nums: List[int]) -> int:
        
        # Note:
        # Bottom Up Tabulation
        # 0. Direct Length -> need for len == 1 check, 2nd element may not exist
        # 1. Direct Array -> 1st + 2nd element
        # 2. Iterate -> 2 to n-1
        # 3. Build From Previous -> grab from previous two steps and sum paths
        # Result: max loot from nth house

        n = len(nums)

        # Direct Length -> need for len == 1 check, 2nd element may not exist
        if n == 1:
            return nums[0]

        # Direct Array -> 1st + 2nd element
        dp = [0] * n
        dp[0], dp[1] = 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
            # choose max between rob or skipping current house
            dp[i] = max(nums[i] + dp[i-2], dp[i-1])

        # res -> max loot at nth house
        res = dp[n-1]

        # overall: time complexity O(n)
        # overall: space complexity O(n)
        return res

Solution 3: 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 res

213. 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 InputOutput
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.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

Solution 1: 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 res

Solution 2: 0 to i Iterative Bottom Up Array - 1D Dynamic Programming/Iterative Tabulation (Direct N) Bottom Up

    def rob(self, nums: List[int]) -> int:
        
        # Note:
        # Bottom up array
        # 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 Array -> 1st + 2nd element
        # 3. Iterate -> 2 to numHouses-1
        # 4. Build From Previous -> grab from previous two steps and sum paths
        # 5. Handle circular constraint -> 2 subsets to break circular array
        # Result: max loot from circular houses 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):

            # Direct Length -> need for len == 1 check, 2nd element may not exist
            m = end - start + 1
            if m == 1:
                return nums[start]

            # Direct Array -> 1st + 2nd element
            dp = [0] * m
            dp[0] = nums[start]
            dp[1] = 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
                # choose max between rob or skipping current house
                dp[i] = max(nums[j] + dp[i-2], dp[i-1])

            # res -> max loot at last house in subarray
            return dp[m-1]


        # 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)
        return res

Solution 3: 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 res

5. Longest Palindromic Substring ::3:: - Medium

Topics: Two Pointers, String, Dynamic Programming

Intro

Given a string s, return the longest palindromic substring in s.

InputOutput
"cbbd""bb"
"babad""bab" or "aba"

Constraints:

1 ≤ s.length ≤ 1000

s consists of only digits and English letters.

Abstraction

Find the longest palindrome in a string.

Find the Bug: Did not create Expanded String

    def longestPalindrome(self, s: str) -> str:
        
        # INCORRECT:
        # did not create expandedStr
        # did not solve odd vs even palindrome problem
        # missing:
        # expandedStr = "#".join(f"^{s}$")
        
        center = 0
        right = 0
        n = len(s)
        p = [0] * n

        for i in range(1, n-1):
            
            # 1 
            mirror = (2*center) - i

            # 2 
            if i < right:
                p[i] = min(right-i, p[mirror])

            # 3
            while s[i + p[i] + 1] == s[i - p[i] - 1]:
                p[i] += 1

            # 4
            if i + p[i] > right:
                center = i
                right = i + p[i]
        
        (maxRadius, center) = max((maxRadius, i) for (i, maxRadius) in enumerate(p))
        start = (center-maxRadius) // 2 

        return s[start:start+maxRadius]

Find the Bug: Defined List instead of Slicing

    def longestPalindrome(self, s: str) -> str:
        
        def expandAroundCenter(left, right):
            while left >= 0 and right < n and s[left] == s[right]:
                left -= 1
                right += 1

            # INCORRECT:
            # defined list instead of slicing
            # should be: s[left+1:right]
            return s[left+1, right]
        
        n = len(s)
        maxPalin = ""

        for i in range(n):
            oddPalin = expandAroundCenter(i, i)
            evenPalin = expandAroundCenter(i, i+1)

            if len(oddPalin) > len(maxPalin):
                maxPalin = oddPalin
            if len(evenPalin) > len(maxPalin):
                maxPalin = evenPalin
                
        return maxPalin

Find the Bug: Bad iteration leading to out of bounds on string expansion

    def longestPalindrome(self, s: str) -> str:
        
        expandedStr = "#".join(f"^{s}$")
        n = len(expandedStr)
        p = [0] * n
        center = 0
        right = 0

        # INCORRECT:
        # iteration will also expand from the sentinals '^' and '$'
        for i in range(n):

            # grab mirror
            mirror = (2*center)-i

            # validate mirror
            if i < right:
                p[i] = min(p[mirror], (right-i))

            # expand
            # INCORRECT:
            # due to iteration, expansion is not guaranteed and prevent
            # out of bounds grab, since we are missing the eventual
            # '^' != '$'
            while expandedStr[i - p[i] - 1] == expandedStr[i + p[i] + 1]:
                p[i] += 1

            # find new right most
            if p[i] + i > right:
                center = i
                right = p[i] + i

        # translate back to height
        maxRadi, center = max((maxRadi, center) for (center, maxRadi) in enumerate(p))

        startIndex = (center-maxRadi)//2


        return s[startIndex:startIndex+maxRadi]

Find the Bug: Bad enumerate method:

    def longestPalindrome(self, s: str) -> str:
        
        expandedStr = "#".join(f"^{s}$")
        n = len(expandedStr)
        p = [0] * n
        center = 0
        right = 0

        for i in range(1, n-1):

            # grab mirror
            mirror = (2*center)-i

            # validate mirror
            if i < right:
                p[i] = min(p[mirror], (right-i))

            # expand
            while expandedStr[i - p[i] - 1] == expandedStr[i + p[i] + 1]:
                p[i] += 1

            # find new right most
            if p[i] + i > right:
                center = i
                right = p[i] + i

        # translate back to height
        # INCORRECT:
        # should be 
        # enumerate(p)
        # instead of p.enumerate()
        maxRadi, center = max((maxRadi, center) for (center, maxRadi) in p.enumerate())

        startIndex = (center-maxRadi)//2


        return s[startIndex:startIndex+maxRadi]

Solution 1: Manacher's Algorithm (iterate, mirror radius optimization, and expand) - Two Pointers/Algorithm

    def longestPalindrome(self, s: str) -> str:

        # Note: 
        # Preprocessing with #, ^, and $:
        # '#': ensures uniform expansion, for both odd and even length palindromes
        # '^' and '$': sentinel characters don't match valid input characters, serve as true start and end markers
        # '#': ensures all palindromes start and end with '#'
        # '#': occur on odd indexes

        # Mapping:
        # we can map odd '#' indexes to their even character:
        # mapping '#' at index 1 to char pair 'a' at index 2, to original 'a' at index 0
        # [ ^ # a # b # a # $ ] -> [ a b a ]    via : originalStart = (expandedCenter - radius) / 2
        #   0 1 2 3 4 5 6 7 8        0 1 2      thus: originalStart = (4 - 3) / 2 = 0
        
        # Boundary expansion: 
        # For any index i, center of palindrome at i can either be: 
        # - character from the original string
        # - placeholder '#'
        # Center definition allows even length palindromes such as "abba", see below,
        # to have a single middle point, allowing the same expanding logic 
        # for even and odd strings for palindrome validation

        # Ex:
        # ^ # a # b # b # a # $    || new string len 11,
        # 0 1 2 3 4 5 6 7 8 9 10   ||
        #           ^              || index 5 center for even length "abba"

        # index 1 palindrome: "#"
        # index 2 palindrome: "#a#"
        # index 5 palindrome: "#a#b#b#a#"
        # etc...

        expandedStr = "#".join(f"^{s}$")
        n = len(expandedStr)

        # Right Most Palindrome and Mirror Trick: 
        # Iteration tracks the right boundary for the current farthest right palindromic substring, 
        # which allows us to take advantage of the mirror radius trick.
        # It speeds up palindrome expansion by starting the current palindrome radius
        # at the radius value of its mirror

        # p[i]: radius of palindrome centered at some index i
        p = [0] * n

        # mirror radius validation: tracking right boundary
        # right: right boundary of the current right most palindrome
        right = 0

        # mirror radius validation: tracking center of right most in order to calculate mirror index
        # center: center index of the current right most palindrome
        center = 0 

        # iteration range ignores sentinel indexes 0 and (n-1): ^ and $
        # i: center of current palindrome
        # time complexity: iterate over list of n length O(n)
        for i in range(1, n - 1):

            # mirror:
            # i is current index being processed
            # i is to the right of center and has a mirror to the left of center
            # ex: center = 6, i = 7 => mirror = (2 * 6) - 7 = 5
            mirror = (2 * center) - i 

            # mirror radius validation:
            # if i lies within bounds of the right most palindrome,
            # the right most palindrome symmetry guarantees that the palindrome radius
            # for the mirror of i, is applicable to i as well,
            # while within the bounds of the right most palindrome
            if i < right:

                # mirror radius is either:
                # - less than the distance between i and right bound,
                #   in which case all of the radius is valid
                # - exceeds bounds and is farther than right bound,
                #   in which case only the radius up until the right bound is valid
                
                # i radius is thus, bounded by minimum between: 
                # - mirror radius
                # - distance from i to the right bound
                p[i] = min(right - i, p[mirror])

            # assumption: if valid mirror, we pre-set p[i] to p[mirror]
            # now expand: expand radius p[i] until palindromic status is broken
            while expandedStr[i + p[i] + 1] == expandedStr[i - p[i] - 1]:
                p[i] += 1

            # p[i]: radius for palindrome at i
            # i: center for palindrome at i
            # check: if we have a new right most boundary, update center and right 
            if i + p[i] > right:
                right = i + p[i]
                center = i

        # expandedStr iteration complete:
        # p[] stores radius of palindrome centered at each index

        # scan p[] grabbing max palindrome radius alongside its center
        maxRadius, centerIndex = max((palindromeRadius, i) for (i, palindromeRadius) in enumerate(p))

        # Note:
        # index and radius are relative to expandedStr, not the original string
        # thus, we need to translate to original string indexes

        # Notice, how in the expanded string, 
        #  - all original characters are on even index
        #  - all original characters have matching # on the left odd index

        # abba =>  ^ # a # b # b # a # $   | a=2, b=4, b=6, a=8
        # 0123 =>  0 1 2 3 4 5 6 7 8 9 10  | #=1, #=3, #=5, #=7

        # aba =>   ^ # a # b # a # $       | a=2, b=4, a=6
        # 012 =>   0 1 2 3 4 5 6 7 8       | #=1, #=3, #=5

        # any palindrome will always end with a '#'.
        # so if we divide the starting odd position by 2, it will always map
        # to an original character.
        # so an easy translation formula is:

        start = (centerIndex - maxRadius) // 2
        
        # splice longest substring

        # overall: time complexity O(n)
        # overall: space complexity O(n)
        return s[start: start + maxRadius]
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks
PreprocessingO(n)O(n)Building expanded stringMemory allocation for processed string O(n)
IteratingO(n)O(1)Iterate over processed string of n length O(n)No additional memory allocation for iteration O(1)
Expanding radiiO(n)O(n)Radius expansion over processed string of n length O(n)Radius array to store radii for each index for string of n length O(n)
Updating mirror boundsO(1)O(1)Updating center and right for right most palindrome in constant O(1)No additional memory allocation for center and right variables O(1)
OverallO(n)O(n)Iterating over expanded string dominates, leading to O(n) time complexity.Expanding string dominates, leading to O(n) space complexity.

Solution 2: Expand Around Center checking for Odd and Even palindromes (constant space) - Two Pointers/Algorithm

    def longestPalindrome(self, s: str) -> str:

        # expand from a given left and right while 
        # maintaining palindrome property
        def expandAroundCenter(left, right):
            while left >= 0 and right < n and s[left] == s[right]:
                left -= 1
                right += 1

            # curr iteration is not valid:
            # ignore left: incrementing index
            # ignore right: noninclusive right slicing
            return s[left+1:right] 
         
        n = len(s)
        maxPalindrome = ""

        # time complexity: iterate over list of n length O(n)
        for i in range(n):
            # odd expansion, centered at i
            oddPalindrome = expandAroundCenter(i, i)      
            # even expansion, centered at i and i + 1
            evenPalindrome = expandAroundCenter(i, i+1)

            # update longest
            if len(oddPalindrome) > len(maxPalindrome):
                maxPalindrome = oddPalindrome
            if len(evenPalindrome) > len(maxPalindrome):
                maxPalindrome = evenPalindrome

        # overall: time complexity O(n^2)
        # overall: space complexity O(1)
        return maxPalindrome
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks
IteratingO(n)O(1)Iterating over list of n length O(n)No additional memory allocation for iteration O(1)
Odd expansionO(n^2)O(1)For each center, expands outward for odd length palindromes n length O(n), for each outer iteration O(n), leading to O(n^2)No additional memory allocation needed during expansion O(1)
Even expansionO(n^2)O(1)For each center, expands outward for even length palindromes n length O(n), for each outer iteration O(n), leading to O(n^2)No additional memory allocation needed during expansion O(1)
Updating longestO(1)O(1)Comparing odd and even length palindrome to current max in constant O(1)No additional memory allocation needed for comparison to current max O(1)
OverallO(n^2)O(1)Even and odd expansion per outer iteration dominate, leading to O(n2)No additional memory allocation required for in place expansion or iteration, leading to constant O(1)

Solution 3: Dynamic Programming - 1D Dynamic Programming/Linear Property Tracking

        def longestPalindrome(self, s: str) -> str:
        
        n = len(s)
        dp = [[False]*n for _ in range(n)]
        start, max_len = 0, 1

        for i in range(n):
            dp[i][i] = True

        for length in range(2, n+1):  # substring length
            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
                        if length > max_len:
                            start, max_len = i, length

        return s[start:start+max_len]
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

647. Palindromic Substrings ::2:: - 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 InputOutput
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.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

Solution 1: 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 count

Solution 2: 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 count

91. 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 InputOutput
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.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

Solution 1: 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 res

Solution 2: 0 to i Iterative Bottom Up Array - 1D Dynamic Programming/Iterative Tabulation (Direct N) 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
        # 0. Direct Length -> need for len == 1 check, 2nd element/first char may not exist
        # 1. Direct Array -> 1st element
        # 2. Iterate -> 1 to n-1
        # 3. Build From Previous -> grab from previous two steps and sum paths
        # Result: total decode ways nth

        n = len(s)

        # Direct Length -> need for len == 0 check, 2nd element/first char may not exist
        if not s:
            return 0

        # Direct Array -> 1st element
        dp = [0] * n
        dp[0] = 1 if s[0] != '0' else 0

        # Iterate -> 1 to n-1
        for i in range(1, n):

            # 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':
                dp[i] += dp[i-1]

            # check double via previous char, if double is valid
            two_digit = int(s[i-1:i+1])
            if 10 <= two_digit <= 26:
                dp[i] += dp[i-2] if i >= 2 else 1

        # res -> number of decode ways for nth
        res = dp[n-1]

        # overall: time complexity O(n)
        # overall: space complexity O(n)
        return res

Solution 3: 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 res

322. 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 InputOutput
coins = [1,2,5], amount = 113
coins = [2], amount = 3-1
coins = [1], amount = 00

Constraints:

1 ≤ coins.length ≤ 12

1 ≤ coins[i] ≤ 231 - 1

0 ≤ amount ≤ 104

Abstraction

Given a array of coin cost and a price, determine the minimum number of coins needed to make the price.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

Solution 1: i to N Recursive with Memoization Top Down - 1D Dynamic Programming/DFS with Caching (Direct N) Top Down with Memoization

    def coinChange(self, coins: List[int], amount: 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. Boundary Check -> exact match
        # 3. Build From Previous -> grab from previous two steps and sum paths
        # 4. Early Prune -> check if current coin leads to possible, else skip
        # Result: min coins to reach amount if possible 

        sentinel = float('inf')

        memo = {}

        def dfs(curr_amount) -> int:

            # Memo Check -> computed previously
            if curr_amount in memo:
                return memo[curr_amount]
            
            # Boundary Check -> exact match
            if curr_amount == 0:
                return 0

            curr_min = sentinel
            
            # Build From Previous -> grab from previous two steps and sum paths
            for coin in coins:

                # Early Prune -> check if current coin leads to possible, else skip
                new_remaining = curr_amount - coin
                if new_remaining >= 0:

                    # check if new min coin beats previous
                    use_coin = dfs(new_remaining)
                    curr_min = min(curr_min, use_coin+1)

            # res -> min coins to reach current amount
            memo[curr_amount] = curr_min
            return memo[curr_amount]

        # Result -> remaining amount set to full, grab min number of coins
        res = dfs(amount)

        # overall: time complexity 
        # overall: space complexity
        return -1 if res == float('inf') else res

Solution 2: 0 to i Iterative Bottom Up Array - 1D Dynamic Programming/Iterative Tabulation (Direct N) Bottom Up

    def coinChange(self, coins: List[int], amount: int) -> int:
        
        # Note:
        # Bottom Up Array
        # 0. Direct Length -> amount=0 check, needs no coins
        # 1. Direct Array -> dp[0] = 0
        # 3. Iterate -> 1 to amount
        # 4. Explore Choices -> try each coin (coin multiple times possible)
        # 5. Build -> min between 
        # Result: min coins to reach amount if possible

        sentinel = float('inf')

        # Direct Array -> 1st element
        dp = [0] * (amount + 1)
        dp[0] = 0 

        # Iterate -> 1 to amount
        for amount in range(1, amount + 1):

            curr_min = sentinel

            # Build From Previous -> grab from previous two steps and sum paths
            for coin in coins:

                # Early Prune -> check if current coin leads to possible, else skip
                new_remaining = amount - coin
                if new_remaining >= 0:

                    # check if new min coin beats previous
                    use_coin = dp[new_remaining] + 1
                    curr_min = min(curr_min, use_coin)

            dp[amount] = curr_min

        # 
        res = dp[amount] if dp[amount] != sentinel else -1

        # overall: time complexity
        # overall: space complexity
        return res

Solution 3: BFS Memo Coin Number Level Order Search - 1D Dynamic Programming/BFS Visited Memo Level Order

    def coinChange(self, coins: List[int], amount: int) -> int:
        
        # Note:
        # BFS Level Order (shortest path) to find min coins
        # 0. Direct Length -> if amount is 0, no coins needed
        # 1. Iterative stack -> (remaining_amount, coins_used_so_far)
        # 2. Visited set -> no revisiting amount
        # 1. Process root -> current state (amount, coins)
        # 2. Explore choices -> subtract each coin to get next remaining amounts
        # 3. Build -> increment steps (coins used) at each BFS level
        # 4. Early stop -> return steps+1 when remaining reaches 0
        # 5. Result -> -1 if queue exhausted (impossible)

        # Direct Length -> if amount is 0, no coins needed
        if amount == 0:
            return 0

        # Iterative stack -> (remaining_amount, coins_used_so_far)
        queue = deque([(amount, 0)])

        # memo_visited set -> no revisiting amount
        memo_visited = set([amount])

        while queue:

            # Process Root -> current state (amount, coins)
            rem, steps = queue.popleft()

            # Build -> increment steps (coins used) at each BFS level
            for coin in coins:

                next_rem = rem - coin

                # Early stop -> found a valid combination
                if next_rem == 0:
                    return steps + 1
                
                # Build -> enqueue next remaining if valid and not visited
                elif next_rem > 0 and next_rem not in visited:
                    visited.add(next_rem)
                    queue.append((next_rem, steps + 1))

        # No combination found
        return -1

152. 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 InputOutput
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.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

Solution 1: (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 result

Solution 2: (Modified Kadane) 0 to i Iterative Bottom Up Array - 1D Dynamic Programming/Iterative Tabulation (Direct N) Bottom Up

    def maxProduct(self, nums: List[int]) -> int:

        # Note:
        # Bottom Up DP with arrays
        # 0. Direct Length -> empty array check
        # 1. Direct Array -> 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 Array -> initialize max_dp, min_dp at index 0
        max_dp = [0] * n
        min_dp = [0] * n
        max_dp[0] = min_dp[0] = result = nums[0]

        # Iterate -> 1 to n-1
        for i in range(1, n):

            # 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]
            max_dp[i] = max(num, max_dp[i-1] * num, min_dp[i-1] * num)
            min_dp[i] = min(num, max_dp[i-1] * num, min_dp[i-1] * num)

            # res -> overall max subarray
            result = max(result, max_dp[i])

        # overall: time complexity O(n)
        # overall: space complexity O(n)
        return result

Solution 3: (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 result

Solution 4: (Modified Kadane) Forward Backward Scan - 1D Dynamic Programming/Opposite Ends Iterative (Direct N) Rolling State Variables Bottom Up

    def maxProduct(self, nums: List[int]) -> int:

        # Note:
        # Forward-Backward Scan (cumulative products)
        # 0. Direct Length -> empty array check
        # 1. Direct Variables -> initialize left and right
        # 2. Iterate -> forward and backward in the same loop
        # 4. Build -> update result with max(left_prod, right_prod)
        # 5. Three Possibilities -> max subarray
        # Result -> grab overall max subarray

        n = len(nums)

        # Direct Length -> empty array check
        if n == 0:
            return 0

        # Direct Variables -> initialize left and right
        left_prod = right_prod = 1
        result = float('-inf')

        # Iterate -> forward and backward in the same loop
        for i in range(n):

            # left scan -> contiguous subarray ending here 
            # right scan -> contiguous subarray starting from here
            # new subarray after zero -> start of new subarray after zero  
            left_prod *= nums[i]
            right_prod *= nums[n - 1 - i]

            # Three Possibilities -> max subarray
            result = max(result, left_prod, right_prod)

            # Reset if zero encountered
            if left_prod == 0:
                left_prod = 1
            if right_prod == 0:
                right_prod = 1

        # overall: time complexity O(n)
        # overall: space complexity O(1)
        return result

139. 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 InputOutput
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.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

Solution 1: 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 res

Solution 2: Optimized 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:
        # Optimized 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. Explore Choices -> iterate only over words in wordDict
        # 3. Build From Previous -> if s[i-len(word):i] matches and dfs(i-len(word)) is True
        # 4. Backtrack -> store False if no valid segmentation
        # Result: can full string be segmented

        n = len(s)

        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 -> try every word in dictionary
            for word in word_set:

                # Build From Previous -> valid segmentation
                # i >= len(word) -> avoid nonsense negative slice: i = 3 -> substring = "app" -> s[i - len(word):i] == s[-2:3] == "pp"
                # s[] -> check if word matches
                if i >= len(word) and s[i - len(word):i] == word:
                    # check if left side after word is segmentable
                    if dfs(i - len(word)):
                        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 * m)  (m = #words)
        # overall: space complexity O(n)
        return res

Solution 3: 0 to i Iterative Bottom Up Array - 1D Dynamic Programming/Sliding Window Iterative (Direct N) Rolling State Variables Bottom Up

    def wordBreak(self, s: str, wordDict: List[str]) -> bool:
        
        # Note:
        # Bottom Up DP (tabulation)
        # 0. Direct Length -> need len == 0 check
        # 1. Direct Array -> empty string is segmentable
        # 2. Iterate -> 1 to n
        # 3. Explore Choices -> try all j < i
        # 4. Build From Previous -> dp[i] = True if dp[j] is True and s[j:i] in dict
        # 5. Early Prune -> break if segmentation found
        # Result: can full string be segmented
 
        n = len(s)

        dp = [False] * (n + 1)

        # quick lookup
        word_set = set(wordDict)

        # Direct Array -> empty string is segmentable
        dp[0] = True

        # Iterate -> 1 to n
        for i in range(1, n+1):

            # Explore Choices -> try all j < i
            for j in range(i):
                
                # Build From Previous -> dp[i] = True if dp[j] is True and s[j:i] in dict
                if dp[j] and s[j:i] in word_set:

                    # Build -> segmentable
                    dp[i] = True 
                    # early prune, found valid segmentation
                    break  

        # Result: can full string be segmented
        res = dp[n]

        # overall: time complexity
        # overall: space complexity
        return res

Solution 4: 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 res

300. 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 InputOutput
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.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

Solution 1: 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 res

Solution 2: Dynamic Programming - 1D Dynamic Programming/Subsequence Optimization Constrained

    def lengthOfLIS(self, nums: List[int]) -> int:

        # Note:
        # Top Down DP (recursive memoization)
        # 0. Direct Length -> empty array boundary covered by recursion
        # 1. Direct array 
        # 2.
        # 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)

        # Direct Length -> empty array boundary covered by recursion
        if n == 0:
            return 0

        # dp[i] = length of LIS ending at i
        dp = [1] * n

        for i in range(n):
            for j in range(i):

                # Explore Choice -> extend subsequence ending at j
                if nums[i] > nums[j]:

                    # Build -> max between current and extending j
                    dp[i] = max(dp[i], dp[j] + 1)

        # res -> max from array
        res = max(dp)

        # overall: time complexity O(n^2)
        # overall: space complexity. O(n) (memo + recursion stack)
        return res

Solution 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 InputOutput
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.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

Solution 1: 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 2: 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