1964. Find the Longest Valid Obstacle Course at Each Position - Explanation

Problem Link



Prerequisites

Before attempting this problem, you should be comfortable with:

  • Dynamic Programming - Used to build optimal subsequences by tracking states
  • Longest Increasing Subsequence (LIS) - This problem is a variant of the classic LIS problem with non-strict inequality
  • Binary Search - Used to optimize the LIS approach from O(n^2) to O(n log n)
  • Upper Bound vs Lower Bound - Understanding when to use each for subsequence problems

1. Dynamic Programming (Top-Down)

Intuition

For each position, we want the longest increasing subsequence ending at that position where each element is less than or equal to the next. This is a variant of the classic LIS problem. We can use memoization: for each index and the previous element chosen, we either include the current element in our sequence (if valid) or skip it.

Algorithm

  1. Create a 2D memoization table dp[i][prev] representing the longest valid sequence considering elements from 0 to i, where prev is the index of the last chosen element.
  2. Define a recursive function dfs(i, prev):
    • Base case: if i < 0, return 0.
    • If already computed, return the cached value.
    • Option 1: Skip the current element, recurse with dfs(i - 1, prev).
    • Option 2: If valid (no previous element or current element is less than or equal to previous), include it and recurse with dfs(i - 1, i).
    • Store and return the maximum.
  3. Call dfs(n - 1, n) to fill the table, then construct the result array from the memoized values.
class Solution:
    def longestObstacleCourseAtEachPosition(self, obstacles: List[int]) -> List[int]:
        n = len(obstacles)
        dp = [[-1] * (n + 1) for _ in range(n)]

        def dfs(i, prev):
            if i < 0:
                return 0
            if dp[i][prev] != -1:
                return dp[i][prev]

            res = dfs(i - 1, prev)
            if prev == n or obstacles[prev] >= obstacles[i]:
                res = max(res, 1 + dfs(i - 1, i))
            dp[i][prev] = res
            return res

        dfs(n - 1, n)
        return [1] + [1 + dp[i - 1][i] for i in range(1, n)]

Time & Space Complexity

  • Time complexity: O(n2)O(n ^ 2)
  • Space complexity: O(n2)O(n ^ 2)

2. Dynamic Programming (Binary Search) - I

Intuition

We can optimize the LIS approach using binary search. Maintain a dp array where dp[i] represents the smallest ending value of all increasing subsequences of length i + 1. For each new element, we find where it fits using binary search (finding the first element greater than it), which tells us the longest subsequence we can extend. We then update that position with the current value to keep subsequences as extensible as possible.

Algorithm

  1. Initialize a dp array of size n + 1 filled with a large value (representing "no sequence of this length yet").
  2. For each obstacle:
    • Use binary search to find the first index in dp where the value is greater than the current obstacle.
    • This index represents the longest valid course ending at this position.
    • Update dp[index] with the current obstacle value.
    • Store index + 1 as the result for this position.
  3. Return the result array.
class Solution:
    def longestObstacleCourseAtEachPosition(self, obstacles: List[int]) -> List[int]:
        res = []
        dp = [10**8] * (len(obstacles) + 1)

        for num in obstacles:
            index = bisect.bisect(dp, num)
            res.append(index + 1)
            dp[index] = num

        return res

Time & Space Complexity

  • Time complexity: O(nlogn)O(n \log n)
  • Space complexity: O(n)O(n)

3. Dynamic Programming (Binary Search) - II

Intuition

This is a space-optimized version of the previous approach. Instead of preallocating an array of size n + 1, we start with an empty array and only grow it as needed. When a new element extends the longest sequence found so far, we append it; otherwise, we update an existing position. This uses only as much space as the length of the longest subsequence.

Algorithm

  1. Initialize an empty dp array.
  2. For each obstacle:
    • Use binary search to find the first index in dp where the value is greater than the current obstacle.
    • If the index equals the current length of dp, append the obstacle (new longest length).
    • Otherwise, update dp[index] with the current obstacle.
    • Store index + 1 as the result for this position.
  3. Return the result array.
class Solution:
    def longestObstacleCourseAtEachPosition(self, obstacles: List[int]) -> List[int]:
        res = []
        dp = []

        for num in obstacles:
            index = bisect.bisect_right(dp, num)
            res.append(index + 1)

            if index == len(dp):
                dp.append(num)
            else:
                dp[index] = num

        return res

Time & Space Complexity

  • Time complexity: O(nlogn)O(n \log n)
  • Space complexity: O(n)O(n)

Common Pitfalls

Using Lower Bound Instead of Upper Bound

This problem allows equal elements in the subsequence (non-strictly increasing), so we need upper_bound (first element greater than target), not lower_bound (first element greater than or equal to target). Using lower bound would treat equal elements as strictly increasing and produce incorrect results.

Confusing with Standard LIS

Standard Longest Increasing Subsequence requires strictly increasing elements and uses lower_bound. This problem requires non-decreasing elements (allowing duplicates), which changes the binary search condition. Applying the standard LIS algorithm directly will undercount valid sequences containing equal consecutive elements.

Forgetting to Update the DP Array

After finding the insertion position using binary search, the dp array must be updated with the current element. Forgetting this update means the algorithm does not track the optimal ending values for each sequence length, leading to incorrect results for subsequent elements.

Returning Indices Instead of Lengths

The binary search returns an index in the dp array, but the answer is the length of the longest valid course. The result for each position is index + 1 (since dp is 0-indexed and the index represents sequences of length index + 1). Returning index directly gives answers that are off by one.

Not Handling the First Element

The first obstacle always has a course length of 1 since there are no previous obstacles. Some implementations may incorrectly initialize or skip this case, especially when using recursive approaches that need careful base case handling.