300. Longest Increasing Subsequence - Explanation

Problem Link

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

Explanation: 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: 4

Constraints:

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


Company Tags

Please upgrade to NeetCode Pro to view company tags.



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

  1. Define dfs(i, j) where i is the current index and j is the index of the last included element (or -1 if none).
  2. Base case: If i reaches the end, return 0.
  3. Option 1: Skip nums[i] with dfs(i + 1, j).
  4. Option 2: If j == -1 or nums[j] < nums[i], include it with 1 + dfs(i + 1, i).
  5. Return the maximum of both options.
class Solution:
    def lengthOfLIS(self, nums: List[int]) -> int:

        def dfs(i, j):
            if i == len(nums):
                return 0

            LIS = dfs(i + 1, j) # not include

            if j == -1 or nums[j] < nums[i]:
                LIS = max(LIS, 1 + dfs(i + 1, i)) # include

            return LIS

        return dfs(0, -1)

Time & Space Complexity

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

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

  1. Create a memo table of size n x (n + 1).
  2. Before computing dfs(i, j), check memo[i][j + 1].
  3. If cached, return the stored value.
  4. 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: O(n2)O(n ^ 2)
  • Space complexity: O(n2)O(n ^ 2)

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

  1. Create a memo array of size n.
  2. Define dfs(i) as the LIS length starting from index i.
  3. For each j from i + 1 to n - 1, if nums[i] < nums[j], consider 1 + dfs(j).
  4. Cache and return the maximum.
  5. The answer is the maximum of dfs(i) for all i.
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: O(n2)O(n ^ 2)
  • Space complexity: O(n)O(n)

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

  1. Create a 2D array dp of size (n + 1) x (n + 1).
  2. Iterate i from n - 1 down to 0.
  3. For each j from i - 1 down to -1:
    • Start with LIS = dp[i + 1][j + 1] (skipping nums[i]).
    • If j == -1 or nums[j] < nums[i], also consider 1 + dp[i + 1][i + 1].
    • Set dp[i][j + 1] to the maximum.
  4. 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: O(n2)O(n ^ 2)
  • Space complexity: O(n2)O(n ^ 2)

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

  1. Create an array LIS of size n, initialized to 1 (each element alone is a subsequence of length 1).
  2. Iterate i from n - 1 down to 0.
  3. For each j from i + 1 to n - 1:
    • If nums[i] < nums[j], set LIS[i] = max(LIS[i], 1 + LIS[j]).
  4. Return the maximum value in LIS.
class Solution:
    def lengthOfLIS(self, nums: List[int]) -> int:
        LIS = [1] * len(nums)

        for i in range(len(nums) - 1, -1, -1):
            for j in range(i + 1, len(nums)):
                if nums[i] < nums[j]:
                    LIS[i] = max(LIS[i], 1 + LIS[j])
        return max(LIS)

Time & Space Complexity

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

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

  1. Compress the array values to indices 0 to n - 1 based on sorted order.
  2. Build a segment tree that supports range maximum queries and point updates.
  3. 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 num with this value.
  4. 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 LIS

Time & Space Complexity

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

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

  1. Initialize dp with the first element.
  2. For each subsequent element nums[i]:
    • If nums[i] is greater than dp[-1], append it to dp.
    • Otherwise, find the leftmost position in dp where dp[pos] >= nums[i] using binary search, and replace dp[pos] with nums[i].
  3. The length of dp is 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 LIS

Time & Space Complexity

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

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.