300. Longest Increasing Subsequence - Explanation
Description
Given an integer array nums, return the length of the longest strictly increasing subsequence.
A subsequence is a sequence that can be derived from the given sequence by deleting some or no elements without changing the relative order of the remaining characters.
- For example,
"cat"is a subsequence of"crabt".
Example 1:
Input: nums = [9,1,4,2,3,3,7]
Output: 4Explanation: The longest increasing subsequence is [1,2,3,7], which has a length of 4.
Example 2:
Input: nums = [0,3,1,3,2,3]
Output: 4Constraints:
1 <= nums.length <= 1000-1000 <= nums[i] <= 1000
Topics
Recommended Time & Space Complexity
You should aim for a solution as good or better than O(n ^ 2) time and O(n ^ 2) space, where n is the size of the input array.
Hint 1
A subsequence is formed by selecting elements while maintaining their order. Using recursion, we can generate all subsequences. The recursive function returns the length of the longest increasing subsequence up to index i, processing from left to right. At each step, we decide whether to include or exclude the current element.
Hint 2
Since the sequence must be increasing, we represent choices by adding 1 when including an element and 0 when excluding it. In recursion, how can we ensure the current element is greater than the previous one? Perhaps additional information is needed to process it.
Hint 3
We can store the index of the previously chosen element as j, making it easier to process the current element at index i. If and only if j == -1 or nums[i] > nums[j], we include the current element and extend the recursive path. Can you determine the recurrence relation? At most, two recursive calls are made at each recursion step.
Hint 4
We stop the recursion when index i goes out of bounds and return 0 since no more elements can be added. The initial recursion call starts with j = -1. At each step, we include the current element if it is greater than the previous one and continue the recursion, or we exclude it and explore the next possibility. We return the maximum value obtained from both paths.
Hint 5
The time complexity of this approach is exponential. We can use memoization to store results of recursive calls and avoid recalculations. A hash map or a 2D array can be used to cache these results.
Prerequisites
Before attempting this problem, you should be comfortable with:
- Dynamic Programming - Both top-down (memoization) and bottom-up approaches for tracking optimal subsequences
- Binary Search - Finding insertion positions in a sorted array to achieve O(n log n) time complexity
- Recursion - Breaking down the problem into subproblems of including or excluding each element
1. Recursion
Intuition
To find the longest increasing subsequence, we consider each element and decide whether to include it. We can only include an element if it's larger than the previous one in our subsequence. This gives us two choices at each step: skip the current element or include it (if valid). We explore all possibilities recursively and return the maximum length found.
Algorithm
- Define
dfs(i, j)whereiis the current index andjis the index of the last included element (or-1if none). - Base case: If
ireaches the end, return0. - Option 1: Skip
nums[i]withdfs(i + 1, j). - Option 2: If
j == -1ornums[j] < nums[i], include it with1 + dfs(i + 1, i). - Return the maximum of both options.
Time & Space Complexity
- Time complexity:
- Space complexity:
2. Dynamic Programming (Top-Down) - I
Intuition
The recursive solution revisits the same (i, j) pairs multiple times. We can memoize results using a 2D table indexed by current position and the last included index. Since j can range from -1 to n-1, we offset by 1 when indexing the memo table.
Algorithm
- Create a memo table of size
n x (n + 1). - Before computing
dfs(i, j), checkmemo[i][j + 1]. - If cached, return the stored value.
- Otherwise, compute using the recursive logic, store in memo, and return.
class Solution:
def lengthOfLIS(self, nums):
n = len(nums)
memo = [[-1] * (n + 1) for _ in range(n)]
def dfs(i, j):
if i == n:
return 0
if memo[i][j + 1] != -1:
return memo[i][j + 1]
LIS = dfs(i + 1, j)
if j == -1 or nums[j] < nums[i]:
LIS = max(LIS, 1 + dfs(i + 1, i))
memo[i][j + 1] = LIS
return LIS
return dfs(0, -1)Time & Space Complexity
- Time complexity:
- Space complexity:
3. Dynamic Programming (Top-Down) - II
Intuition
Instead of tracking both current index and last included index, we can define dfs(i) as the length of the longest increasing subsequence starting at index i. For each starting position, we look at all positions j > i where nums[j] > nums[i] and take the maximum. This reduces the state to just one dimension.
Algorithm
- Create a
memoarray of sizen. - Define
dfs(i)as the LIS length starting from indexi. - For each
jfromi + 1ton - 1, ifnums[i] < nums[j], consider1 + dfs(j). - Cache and return the maximum.
- The answer is the maximum of
dfs(i)for alli.
class Solution:
def lengthOfLIS(self, nums: List[int]) -> int:
n = len(nums)
memo = [-1] * n
def dfs(i):
if memo[i] != -1:
return memo[i]
LIS = 1
for j in range(i + 1, n):
if nums[i] < nums[j]:
LIS = max(LIS, 1 + dfs(j))
memo[i] = LIS
return LIS
return max(dfs(i) for i in range(n))Time & Space Complexity
- Time complexity:
- Space complexity:
4. Dynamic Programming (Bottom-Up) - I
Intuition
This is the iterative version of the two-dimensional memoization approach. We fill a 2D table dp[i][j] from right to left. The value represents the longest increasing subsequence considering elements from index i onward, given that the last included element was at index j (or no element if j == -1).
Algorithm
- Create a 2D array
dpof size(n + 1) x (n + 1). - Iterate
ifromn - 1down to0. - For each
jfromi - 1down to-1:- Start with
LIS = dp[i + 1][j + 1](skippingnums[i]). - If
j == -1ornums[j] < nums[i], also consider1 + dp[i + 1][i + 1]. - Set
dp[i][j + 1]to the maximum.
- Start with
- Return
dp[0][0].
class Solution:
def lengthOfLIS(self, nums):
n = len(nums)
dp = [[0] * (n + 1) for _ in range(n + 1)]
for i in range(n - 1, -1, -1):
for j in range(i - 1, -2, -1):
LIS = dp[i + 1][j + 1] # Not including nums[i]
if j == -1 or nums[j] < nums[i]:
LIS = max(LIS, 1 + dp[i + 1][i + 1]) # Including nums[i]
dp[i][j + 1] = LIS
return dp[0][0]Time & Space Complexity
- Time complexity:
- Space complexity:
5. Dynamic Programming (Bottom-Up) - II
Intuition
A simpler 1D approach: let LIS[i] be the length of the longest increasing subsequence starting at index i. Working from right to left, for each i, we check all j > i. If nums[i] < nums[j], we can extend the subsequence starting at j. We take the maximum extension and add 1 for the current element.
Algorithm
- Create an array
LISof sizen, initialized to1(each element alone is a subsequence of length 1). - Iterate
ifromn - 1down to0. - For each
jfromi + 1ton - 1:- If
nums[i] < nums[j], setLIS[i] = max(LIS[i], 1 + LIS[j]).
- If
- Return the maximum value in
LIS.
Time & Space Complexity
- Time complexity:
- Space complexity:
6. Segment Tree
Intuition
We can use a segment tree to efficiently query the maximum LIS length among all elements smaller than the current one. First, we compress the values to indices. Then, for each element, we query the segment tree for the maximum LIS among all values less than the current value, add 1, and update the segment tree at the current value's position.
Algorithm
- Compress the array values to indices
0ton - 1based on sorted order. - Build a segment tree that supports range maximum queries and point updates.
- For each compressed value
num:- Query the maximum in range
[0, num - 1]. - The current LIS ending here is
query result + 1. - Update the segment tree at position
numwith this value.
- Query the maximum in range
- Return the overall maximum LIS found.
from bisect import bisect_left
class SegmentTree:
def __init__(self, N):
self.n = N
while (self.n & (self.n - 1)) != 0:
self.n += 1
self.tree = [0] * (2 * self.n)
def update(self, i, val):
self.tree[self.n + i] = val
j = (self.n + i) >> 1
while j >= 1:
self.tree[j] = max(self.tree[j << 1], self.tree[j << 1 | 1])
j >>= 1
def query(self, l, r):
if l > r:
return 0
res = float('-inf')
l += self.n
r += self.n + 1
while l < r:
if l & 1:
res = max(res, self.tree[l])
l += 1
if r & 1:
r -= 1
res = max(res, self.tree[r])
l >>= 1
r >>= 1
return res
class Solution:
def lengthOfLIS(self, nums: List[int]) -> int:
def compress(arr):
sortedArr = sorted(set(arr))
order = []
for num in arr:
order.append(bisect_left(sortedArr, num))
return order
nums = compress(nums)
n = len(nums)
segTree = SegmentTree(n)
LIS = 0
for num in nums:
curLIS = segTree.query(0, num - 1) + 1
segTree.update(num, curLIS)
LIS = max(LIS, curLIS)
return LISTime & Space Complexity
- Time complexity:
- Space complexity:
7. Dynamic Programming + Binary Search
Intuition
We maintain an array dp where dp[i] is the smallest ending element of all increasing subsequences of length i + 1. This array stays sorted. For each new element, if it's larger than the last element in dp, it extends the longest subsequence. Otherwise, we use binary search to find the position where it can replace an element, keeping the array optimal for future extensions.
Algorithm
- Initialize
dpwith the first element. - For each subsequent element
nums[i]:- If
nums[i]is greater thandp[-1], append it todp. - Otherwise, find the leftmost position in
dpwheredp[pos] >= nums[i]using binary search, and replacedp[pos]withnums[i].
- If
- The length of
dpis the answer.
from bisect import bisect_left
class Solution:
def lengthOfLIS(self, nums: List[int]) -> int:
dp = []
dp.append(nums[0])
LIS = 1
for i in range(1, len(nums)):
if dp[-1] < nums[i]:
dp.append(nums[i])
LIS += 1
continue
idx = bisect_left(dp, nums[i])
dp[idx] = nums[i]
return LISTime & Space Complexity
- Time complexity:
- Space complexity:
Common Pitfalls
Confusing the Binary Search Array with the Actual LIS
In the binary search approach, the dp array at the end does not contain the actual longest increasing subsequence. It contains the smallest tail elements for subsequences of each length. The length of this array is the LIS length, but the elements themselves may not form a valid increasing subsequence from the original array.
Using Wrong Binary Search Condition
When finding the position to replace in the dp array, you need to find the leftmost position where dp[pos] >= nums[i]. Using > instead of >= can lead to duplicate values being treated as increasing, which violates the strictly increasing requirement.
Returning Wrong Value in Bottom-Up DP
In the O(n^2) DP approach, the answer is the maximum value across all dp[i], not just dp[n-1]. The longest increasing subsequence might end at any index, not necessarily the last one. Returning only the last element of the DP array misses cases where the LIS ends earlier in the array.
Sign in to join the discussion