title: Longest Increasing Subsequence
slug: longest-increasing-subsequence
difficulty: medium
leetcode_id: 300
leetcode_url: https://leetcode.com/problems/longest-increasing-subsequence/
categories:
  - arrays
  - dynamic-programming
  - binary-search
patterns:
  - dynamic-programming
  - binary-search

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 an array by deleting some or no elements without changing the order of the remaining elements.

constraints: |
  - `1 <= nums.length <= 2500`
  - `-10^4 <= nums[i] <= 10^4`

examples:
  - input: "nums = [10,9,2,5,3,7,101,18]"
    output: "4"
    explanation: "The longest increasing subsequence is [2,3,7,101], therefore the length is 4."
  - input: "nums = [0,1,0,3,2,3]"
    output: "4"
    explanation: "One possible longest increasing subsequence is [0,1,2,3]."
  - input: "nums = [7,7,7,7,7,7,7]"
    output: "1"
    explanation: "The longest increasing subsequence is any single element, since all elements are equal and a strictly increasing sequence cannot have duplicates."

explanation:
  intuition: |
    Imagine you're building a tower of blocks where each block you add must be larger than the one below it. You have a sequence of blocks laid out in a row, and you must pick blocks from left to right (you can skip blocks, but you can't go backwards).

    The question becomes: what's the tallest tower you can build?

    The key insight is that for each position in the array, we want to know: **"What's the longest increasing subsequence that ends at this position?"** If we can answer this for every position, the answer to the original problem is simply the maximum of all these values.

    Think of it like this: when you're at position `i`, you look back at all previous positions `j` where `nums[j] < nums[i]`. You can extend any increasing subsequence ending at `j` by adding `nums[i]` to it. So the longest subsequence ending at `i` is one more than the longest subsequence ending at any valid `j`.

    For optimal O(n log n) time, we use a different mental model: maintain a "patience sorting" pile where we track the smallest ending element for subsequences of each length. This allows us to use binary search to efficiently find where each new element fits.

  approach: |
    We'll cover two approaches: the classic DP solution and the optimised binary search solution.

    **Approach A: Dynamic Programming (O(n^2))**

    **Step 1: Initialise the DP array**

    - Create a `dp` array where `dp[i]` represents the length of the longest increasing subsequence ending at index `i`
    - Initialise all values to `1` since every element is a subsequence of length 1 by itself

    &nbsp;

    **Step 2: Fill the DP array**

    - For each index `i` from `1` to `n-1`, look at all previous indices `j` from `0` to `i-1`
    - If `nums[j] < nums[i]`, we can extend the subsequence ending at `j` by adding `nums[i]`
    - Update: `dp[i] = max(dp[i], dp[j] + 1)`

    &nbsp;

    **Step 3: Return the maximum**

    - The answer is `max(dp)` since the longest subsequence might end at any position

    &nbsp;

    **Approach B: Binary Search with Patience Sorting (O(n log n))**

    **Step 1: Initialise a "tails" array**

    - `tails[i]` represents the smallest ending element of all increasing subsequences of length `i+1`
    - Start with an empty array

    &nbsp;

    **Step 2: Process each element**

    - For each number in `nums`, use binary search to find its position in `tails`
    - If the number is larger than all elements in `tails`, append it (we found a longer subsequence)
    - Otherwise, replace the first element in `tails` that is >= the current number
    - This maintains the invariant that `tails` is always sorted

    &nbsp;

    **Step 3: Return the length**

    - The length of `tails` is the answer

  common_pitfalls:
    - title: Confusing Subsequence with Subarray
      description: |
        A **subsequence** allows skipping elements while maintaining relative order. A **subarray** must be contiguous.

        For `[10,9,2,5,3,7,101,18]`:
        - `[2,5,7,101]` is a valid subsequence (not contiguous, but maintains order)
        - `[9,2,5]` is both a subarray and subsequence

        Using a sliding window or subarray approach will give wrong answers since you'd miss non-contiguous increasing sequences.
      wrong_approach: "Sliding window for contiguous elements"
      correct_approach: "DP considering all previous elements or binary search on tails"

    - title: Forgetting Strictly Increasing
      description: |
        The problem asks for **strictly increasing**, meaning equal elements don't count.

        For `[1,3,3,5]`, the LIS is `[1,3,5]` with length 3, NOT `[1,3,3,5]` with length 4.

        In the DP approach, use `nums[j] < nums[i]` (strict inequality), not `<=`.
        In the binary search approach, use `bisect_left` (not `bisect_right`) to handle duplicates correctly.
      wrong_approach: "Using <= instead of < for comparison"
      correct_approach: "Strict inequality: nums[j] < nums[i]"

    - title: O(n^2) Time Limit on Large Inputs
      description: |
        The basic DP solution is O(n^2). With `n = 2500`, this means up to 6.25 million operations, which is acceptable for this problem.

        However, if constraints were larger (e.g., `n = 10^5`), the DP approach would be too slow. The binary search approach scales to O(n log n) for such cases.

        Always check constraints to decide which approach is needed.
      wrong_approach: "Always using O(n^2) DP without checking constraints"
      correct_approach: "Use binary search for larger inputs"

    - title: Misunderstanding the Tails Array
      description: |
        The `tails` array in the binary search approach does **not** store an actual LIS. It stores the smallest possible ending element for subsequences of each length.

        For `[10,9,2,5,3,7]`:
        - After processing: `tails = [2,3,7]`
        - But `[2,3,7]` happens to be a valid LIS here
        - For `[3,1,2]`: `tails = [1,2]`, but `[1,2]` is not from the original subsequence `[3,1,2]` as `1` comes after `3`

        The length of `tails` is always correct, but its contents may not form a valid subsequence from the input.
      wrong_approach: "Thinking tails array contains the actual LIS"
      correct_approach: "Understand tails gives length only, not the actual subsequence"

  key_takeaways:
    - "**Classic DP pattern**: When computing properties of subsequences, think about what information you need at each position and how previous positions contribute"
    - "**Patience sorting insight**: Maintaining sorted auxiliary structures enables binary search optimisation, reducing O(n^2) to O(n log n)"
    - "**Foundation for harder problems**: LIS appears in many variations (longest bitonic subsequence, Russian doll envelopes, box stacking) and understanding both approaches unlocks these"
    - "**Subsequence vs subarray**: Always clarify whether the problem allows skipping elements \u2014 this fundamentally changes the approach"

  time_complexity: "O(n^2) for dynamic programming, O(n log n) for binary search. The DP approach compares each element with all previous elements; the binary search approach performs a log n search for each of n elements."
  space_complexity: "O(n). Both approaches use an auxiliary array of size n (`dp` array for DP, `tails` array for binary search)."

solutions:
  - approach_name: Binary Search (Patience Sorting)
    is_optimal: true
    code: |
      import bisect

      def length_of_lis(nums: list[int]) -> int:
          # tails[i] = smallest ending element of all increasing
          # subsequences of length i+1
          tails = []

          for num in nums:
              # Find position where num should be inserted
              # bisect_left handles duplicates correctly (strict increase)
              pos = bisect.bisect_left(tails, num)

              if pos == len(tails):
                  # num is larger than all tails - extend longest subsequence
                  tails.append(num)
              else:
                  # Replace to maintain smallest possible tail
                  tails[pos] = num

          # Length of tails = length of longest increasing subsequence
          return len(tails)
    explanation: |
      **Time Complexity:** O(n log n) \u2014 For each of n elements, we perform a binary search in O(log n).

      **Space Complexity:** O(n) \u2014 The tails array can grow up to size n.

      This approach maintains an array where `tails[i]` is the smallest ending element of all increasing subsequences of length `i+1`. By keeping tails sorted and using binary search, we efficiently determine whether to extend the longest subsequence or update an existing length's tail.

  - approach_name: Dynamic Programming
    is_optimal: false
    code: |
      def length_of_lis(nums: list[int]) -> int:
          n = len(nums)
          # dp[i] = length of longest increasing subsequence ending at i
          dp = [1] * n  # Every element is a subsequence of length 1

          for i in range(1, n):
              # Check all previous elements
              for j in range(i):
                  # If we can extend the subsequence ending at j
                  if nums[j] < nums[i]:
                      dp[i] = max(dp[i], dp[j] + 1)

          # LIS can end at any position
          return max(dp)
    explanation: |
      **Time Complexity:** O(n^2) \u2014 Nested loops comparing each element with all previous elements.

      **Space Complexity:** O(n) \u2014 The dp array stores one value per element.

      This classic DP solution builds up the answer by computing, for each position, the longest increasing subsequence that ends at that position. The final answer is the maximum across all positions. While intuitive, this approach is slower than the binary search method for large inputs.

  - approach_name: Brute Force (Recursion with Memoisation)
    is_optimal: false
    code: |
      def length_of_lis(nums: list[int]) -> int:
          from functools import lru_cache

          n = len(nums)

          @lru_cache(maxsize=None)
          def lis_ending_at(index: int) -> int:
              """Return length of LIS ending at index."""
              # Base case: subsequence of just this element
              max_length = 1

              # Try extending from any valid previous position
              for prev in range(index):
                  if nums[prev] < nums[index]:
                      max_length = max(max_length, 1 + lis_ending_at(prev))

              return max_length

          # LIS can end at any position
          return max(lis_ending_at(i) for i in range(n))
    explanation: |
      **Time Complexity:** O(n^2) \u2014 Same as iterative DP due to memoisation.

      **Space Complexity:** O(n) \u2014 Memoisation cache and recursion stack.

      This recursive approach with memoisation is equivalent to the iterative DP but may be more intuitive for some. The recurrence relation is clear: the LIS ending at index `i` is 1 plus the maximum LIS ending at any previous index `j` where `nums[j] < nums[i]`. Included to show the connection between recursion and DP.
