1964. Find the Longest Valid Obstacle Course at Each Position - Explanation
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
- Create a 2D memoization table
dp[i][prev]representing the longest valid sequence considering elements from0toi, whereprevis the index of the last chosen element. - Define a recursive function
dfs(i, prev):- Base case: if
i < 0, return0. - 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.
- Base case: if
- 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:
- Space complexity:
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
- Initialize a
dparray of sizen + 1filled with a large value (representing "no sequence of this length yet"). - For each obstacle:
- Use binary search to find the first index in
dpwhere 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 + 1as the result for this position.
- Use binary search to find the first index in
- Return the result array.
Time & Space Complexity
- Time complexity:
- Space complexity:
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
- Initialize an empty
dparray. - For each obstacle:
- Use binary search to find the first index in
dpwhere 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 + 1as the result for this position.
- Use binary search to find the first index in
- 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 resTime & Space Complexity
- Time complexity:
- Space complexity:
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.