title: Maximum Length of Pair Chain
slug: maximum-length-of-pair-chain
difficulty: medium
leetcode_id: 646
leetcode_url: https://leetcode.com/problems/maximum-length-of-pair-chain/
categories:
  - arrays
  - sorting
  - dynamic-programming
patterns:
  - slug: greedy
    is_optimal: false
  - slug: intervals
    is_optimal: true

function_signature: "def find_longest_chain(pairs: list[list[int]]) -> int:"

test_cases:
  visible:
    - input: { pairs: [[1, 2], [2, 3], [3, 4]] }
      expected: 2
    - input: { pairs: [[1, 2], [7, 8], [4, 5]] }
      expected: 3
  hidden:
    - input: { pairs: [[1, 2]] }
      expected: 1
    - input: { pairs: [[1, 10], [2, 3], [4, 5]] }
      expected: 2
    - input: { pairs: [[-10, -5], [-3, 0], [1, 5]] }
      expected: 3
    - input: { pairs: [[1, 5], [2, 3], [4, 6]] }
      expected: 2
    - input: { pairs: [[1, 2], [3, 4], [5, 6], [7, 8]] }
      expected: 4

description: |
  You are given an array of `n` pairs `pairs` where `pairs[i] = [left_i, right_i]` and `left_i < right_i`.

  A pair `p2 = [c, d]` **follows** a pair `p1 = [a, b]` if `b < c`. A **chain** of pairs can be formed in this fashion.

  Return *the length of the longest chain which can be formed*.

  You do not need to use up all the given intervals. You can select pairs in any order.

constraints: |
  - `n == pairs.length`
  - `1 <= n <= 1000`
  - `-1000 <= left_i < right_i <= 1000`

examples:
  - input: "pairs = [[1,2],[2,3],[3,4]]"
    output: "2"
    explanation: "The longest chain is [1,2] -> [3,4]. Note that [1,2] -> [2,3] is invalid because 2 is not strictly less than 2."
  - input: "pairs = [[1,2],[7,8],[4,5]]"
    output: "3"
    explanation: "The longest chain is [1,2] -> [4,5] -> [7,8]."

explanation:
  intuition: |
    Imagine you're scheduling non-overlapping meetings in a conference room, but with a twist — between each meeting, you need at least one time unit of buffer. Your goal is to fit as many meetings as possible.

    The key insight is that **greedily picking pairs that end earliest** leaves the most room for subsequent pairs. If you always choose the pair with the smallest ending value (right bound) that can legally follow the current chain, you maximise your opportunities to extend the chain further.

    Think of it like this: given two pairs that could both extend your current chain, the one ending earlier gives you more flexibility for what comes next. This is the classic **activity selection** problem from algorithm theory.

    Why does greedy work here? Because the "follow" relationship only depends on the ending value of the previous pair. By sorting on end values and greedily extending, we make locally optimal choices that lead to the globally optimal solution.

  approach: |
    We solve this using the **Greedy Approach** (sorting by end values):

    **Step 1: Sort pairs by their ending value**

    - Sort the `pairs` array by each pair's right (ending) element
    - This ensures we always consider pairs that "finish earliest" first
    - Pairs that end earlier leave more room for subsequent pairs

    &nbsp;

    **Step 2: Initialise tracking variables**

    - `chain_length`: Set to `1` since we'll always include at least the first pair
    - `current_end`: Set to the end value of the first pair after sorting

    &nbsp;

    **Step 3: Iterate through the remaining pairs**

    - For each pair starting from index 1, check if its start value is greater than `current_end`
    - If `pair[0] > current_end`, this pair can extend the chain:
      - Increment `chain_length` by 1
      - Update `current_end` to this pair's end value
    - If not, skip this pair — it overlaps with our current chain end

    &nbsp;

    **Step 4: Return the result**

    - Return `chain_length` as the maximum chain length

    &nbsp;

    This greedy approach works because by always choosing the pair that ends earliest, we maximise the remaining space for future pairs, leading to the longest possible chain.

  common_pitfalls:
    - title: Sorting by Start Value Instead of End Value
      description: |
        A common mistake is sorting by the start value (left bound) instead of the end value.

        Consider `pairs = [[1,5],[2,3],[4,6]]`:
        - Sorted by start: `[[1,5],[2,3],[4,6]]` → Chain `[1,5] -> [4,6]` = length 2
        - Sorted by end: `[[2,3],[1,5],[4,6]]` → Chain `[2,3] -> [4,6]` = length 2

        But with `[[1,10],[2,3],[4,5]]`:
        - Sorted by start: `[[1,10],[2,3],[4,5]]` → Only `[1,10]` = length 1
        - Sorted by end: `[[2,3],[4,5],[1,10]]` → Chain `[2,3] -> [4,5]` = length 2

        The pair `[1,10]` "blocks" everything when chosen first, but sorting by end value reveals that `[2,3]` and `[4,5]` fit together.
      wrong_approach: "Sort by left_i (start value)"
      correct_approach: "Sort by right_i (end value)"

    - title: Using >= Instead of > for the Follow Condition
      description: |
        The problem states `p2` follows `p1` if `b < c` (strictly less than). Using `<=` would incorrectly allow pairs like `[1,2]` to be followed by `[2,3]`.

        With `pairs = [[1,2],[2,3]]`:
        - Correct (`b < c`): `[1,2]` cannot be followed by `[2,3]` because `2 < 2` is false
        - Wrong (`b <= c`): Would incorrectly chain them

        Always use strict inequality: `pair[0] > current_end`.
      wrong_approach: "Check if pair[0] >= current_end"
      correct_approach: "Check if pair[0] > current_end (strict inequality)"

    - title: Using Dynamic Programming When Greedy Suffices
      description: |
        This problem can be solved with DP (O(n^2) time), but the greedy approach is more efficient (O(n log n) due to sorting).

        While DP works by computing `dp[i]` = longest chain ending at pair `i`, the greedy approach recognises that we only need to track the current chain's end value. The insight that "ending earliest is always best" eliminates the need to consider all possible previous pairs.

        With `n <= 1000`, both approaches pass, but greedy is cleaner and faster.
      wrong_approach: "O(n^2) DP computing all subproblems"
      correct_approach: "O(n log n) greedy with sorting"

  key_takeaways:
    - "**Activity selection pattern**: When selecting non-overlapping intervals, sort by end time and greedily pick the earliest-ending valid option"
    - "**Greedy vs DP**: Recognise when greedy provides the optimal solution — here, the locally optimal choice (earliest end) leads to the global optimum"
    - "**Sorting as a preprocessing step**: Many interval problems become tractable once sorted by start or end values"
    - "**Related problems**: This pattern applies to Meeting Rooms II, Non-overlapping Intervals, and Minimum Number of Arrows to Burst Balloons"

  time_complexity: "O(n log n). Sorting dominates the time complexity. The subsequent single pass through the sorted array is O(n)."
  space_complexity: "O(1) or O(n). Depends on the sorting implementation — in-place sorting uses O(1) extra space, while Python's Timsort uses O(n) auxiliary space."

solutions:
  - approach_name: Greedy (Sort by End Value)
    is_optimal: true
    code: |
      def find_longest_chain(pairs: list[list[int]]) -> int:
          # Sort pairs by their ending value (right bound)
          # This ensures we always consider pairs that "finish earliest" first
          pairs.sort(key=lambda x: x[1])

          # Start with the first pair in our chain
          chain_length = 1
          current_end = pairs[0][1]

          # Try to extend the chain with each subsequent pair
          for i in range(1, len(pairs)):
              # Can this pair follow our current chain?
              # The start must be strictly greater than our chain's end
              if pairs[i][0] > current_end:
                  chain_length += 1
                  current_end = pairs[i][1]  # Update chain end

          return chain_length
    explanation: |
      **Time Complexity:** O(n log n) — Sorting dominates; the linear scan is O(n).

      **Space Complexity:** O(1) extra space (ignoring the space used by sorting).

      By sorting pairs by their end values, we ensure that greedily picking the first valid pair always leads to the optimal solution. This is the classic activity selection algorithm.

  - approach_name: Dynamic Programming
    is_optimal: false
    code: |
      def find_longest_chain(pairs: list[list[int]]) -> int:
          # Sort pairs by start value for DP approach
          pairs.sort(key=lambda x: x[0])
          n = len(pairs)

          # dp[i] = longest chain ending with pairs[i]
          dp = [1] * n

          # For each pair, check all previous pairs
          for i in range(1, n):
              for j in range(i):
                  # Can pairs[j] be followed by pairs[i]?
                  if pairs[j][1] < pairs[i][0]:
                      dp[i] = max(dp[i], dp[j] + 1)

          # Return the maximum chain length
          return max(dp)
    explanation: |
      **Time Complexity:** O(n^2) — Nested loops comparing all pairs.

      **Space Complexity:** O(n) — DP array storing chain lengths.

      This approach computes the longest chain ending at each pair by checking all previous pairs. While correct, it's less efficient than the greedy approach. Included to show the DP perspective and for cases where greedy intuition isn't immediately clear.
