title: Best Sightseeing Pair
slug: best-sightseeing-pair
difficulty: medium
leetcode_id: 1014
leetcode_url: https://leetcode.com/problems/best-sightseeing-pair/
categories:
  - arrays
  - dynamic-programming
patterns:
  - greedy

description: |
  You are given an integer array `values` where `values[i]` represents the value of the i<sup>th</sup> sightseeing spot. Two sightseeing spots `i` and `j` have a **distance** `j - i` between them.

  The score of a pair (`i < j`) of sightseeing spots is `values[i] + values[j] + i - j`: the sum of the values of the sightseeing spots, minus the distance between them.

  Return *the maximum score of a pair of sightseeing spots*.

constraints: |
  - `2 <= values.length <= 5 * 10^4`
  - `1 <= values[i] <= 1000`

examples:
  - input: "values = [8,1,5,2,6]"
    output: "11"
    explanation: "i = 0, j = 2, values[i] + values[j] + i - j = 8 + 5 + 0 - 2 = 11"
  - input: "values = [1,2]"
    output: "2"
    explanation: "i = 0, j = 1, values[i] + values[j] + i - j = 1 + 2 + 0 - 1 = 2"

explanation:
  intuition: |
    The key insight comes from **algebraically rearranging** the score formula.

    The score for a pair `(i, j)` where `i < j` is:
    ```
    score = values[i] + values[j] + i - j
    ```

    We can regroup this as:
    ```
    score = (values[i] + i) + (values[j] - j)
    ```

    Think of it like this: each sightseeing spot has two "personalities":
    - As a **starting point** (spot `i`): its contribution is `values[i] + i`
    - As an **ending point** (spot `j`): its contribution is `values[j] - j`

    The total score is simply the sum of the best starting contribution plus the current ending contribution. As we walk through the array from left to right, we track the **maximum starting contribution** we've seen so far. For each new position, we calculate what score we'd get if we ended here.

    This transforms an O(n^2) problem of checking all pairs into an O(n) single-pass solution.

  approach: |
    We solve this using a **Single Pass (Greedy) Approach** by tracking the best starting point as we iterate:

    **Step 1: Understand the formula decomposition**

    - Original: `values[i] + values[j] + i - j`
    - Rearranged: `(values[i] + i) + (values[j] - j)`
    - The first term depends only on `i`, the second only on `j`

    &nbsp;

    **Step 2: Initialise variables**

    - `max_start`: Set to `values[0] + 0` (the starting contribution of the first spot)
    - `max_score`: Set to `0` (will be updated as we find valid pairs)

    &nbsp;

    **Step 3: Iterate from index 1 to end**

    - For each position `j`, calculate the ending contribution: `values[j] - j`
    - Compute the score if we pair with our best starting point: `max_start + (values[j] - j)`
    - Update `max_score` if this is better than what we've seen
    - Update `max_start` if the current position has a better starting contribution: `values[j] + j`

    &nbsp;

    **Step 4: Return the result**

    - Return `max_score` after processing all positions

    &nbsp;

    The greedy choice works because we always pair the current ending point with the best possible starting point seen so far, guaranteeing we don't miss the optimal pair.

  common_pitfalls:
    - title: The Brute Force Trap
      description: |
        The naive approach is to check every pair `(i, j)` where `i < j`:
        - Outer loop for `i` from `0` to `n-2`
        - Inner loop for `j` from `i+1` to `n-1`

        This results in **O(n^2) time complexity**. With `values.length <= 5 * 10^4`, this means up to 1.25 billion operations, which will cause a **Time Limit Exceeded (TLE)** error.
      wrong_approach: "Nested loops checking all pairs"
      correct_approach: "Single pass tracking maximum starting contribution"

    - title: Forgetting the Index Contribution
      description: |
        It's tempting to just track the maximum value seen so far, but the **index matters**. A spot with `values[i] = 100` at index `i = 0` has starting contribution `100`, while the same value at index `i = 50` has contribution `150`.

        The formula `values[i] + i` captures both the spot's value and its position advantage. Earlier spots "decay" as `j` increases, so a later spot with a slightly lower value might actually be better.
      wrong_approach: "Tracking max(values[i]) instead of max(values[i] + i)"
      correct_approach: "Track max(values[i] + i) as the starting contribution"

    - title: Not Updating max_start After Calculating Score
      description: |
        The order of operations matters. You must:
        1. First, calculate the score using the current `max_start`
        2. Then, update `max_start` if the current position is better

        If you update `max_start` before calculating the score at position `j`, you might incorrectly use `j` as both the starting and ending point, violating the `i < j` constraint.
      wrong_approach: "Updating max_start before calculating current score"
      correct_approach: "Calculate score first, then update max_start"

  key_takeaways:
    - "**Formula decomposition**: Rearranging mathematical expressions can reveal independent components that enable efficient algorithms"
    - "**Greedy optimisation**: When a formula splits into parts depending on different indices, track the optimal value of earlier parts as you iterate"
    - "**Similar to Best Time to Buy and Sell Stock**: Both problems track a running optimal value while iterating, transforming O(n^2) into O(n)"
    - "**Index as part of value**: Some problems embed positional information into the scoring, requiring you to consider both value and position together"

  time_complexity: "O(n). We traverse the array exactly once, performing constant-time operations at each step."
  space_complexity: "O(1). We only use two variables (`max_start` and `max_score`), regardless of input size."

solutions:
  - approach_name: Single Pass (Greedy)
    is_optimal: true
    code: |
      def max_score_sightseeing_pair(values: list[int]) -> int:
          # Starting contribution of first spot: values[0] + 0
          max_start = values[0]
          max_score = 0

          # Iterate from second spot onwards
          for j in range(1, len(values)):
              # Calculate score if we end at position j
              # Using best starting point seen so far
              current_score = max_start + values[j] - j
              max_score = max(max_score, current_score)

              # Update best starting contribution for future positions
              # Current position j has starting contribution values[j] + j
              max_start = max(max_start, values[j] + j)

          return max_score
    explanation: |
      **Time Complexity:** O(n) — Single pass through the array.

      **Space Complexity:** O(1) — Only two variables used.

      We decompose the score formula into `(values[i] + i) + (values[j] - j)`. By tracking the maximum `values[i] + i` seen so far, we can compute the optimal score ending at each position `j` in constant time.

  - approach_name: Brute Force
    is_optimal: false
    code: |
      def max_score_sightseeing_pair(values: list[int]) -> int:
          max_score = 0
          n = len(values)

          # Try every pair (i, j) where i < j
          for i in range(n):
              for j in range(i + 1, n):
                  # Calculate score for this pair
                  score = values[i] + values[j] + i - j
                  max_score = max(max_score, score)

          return max_score
    explanation: |
      **Time Complexity:** O(n^2) — Nested loops checking all pairs.

      **Space Complexity:** O(1) — Only tracking max_score.

      This approach checks every valid pair and computes the score directly from the formula. While correct, it's too slow for large inputs and will result in TLE on LeetCode. Included here to illustrate why the optimised approach is necessary.
