title: Burst Balloons
slug: burst-balloons
difficulty: hard
leetcode_id: 312
leetcode_url: https://leetcode.com/problems/burst-balloons/
categories:
  - arrays
  - dynamic-programming
patterns:
  - dynamic-programming

function_signature: "def max_coins(nums: list[int]) -> int:"

test_cases:
  visible:
    - input: { nums: [3, 1, 5, 8] }
      expected: 167
    - input: { nums: [1, 5] }
      expected: 10
  hidden:
    - input: { nums: [1] }
      expected: 1
    - input: { nums: [9, 76, 64, 21] }
      expected: 116718
    - input: { nums: [5, 7, 8] }
      expected: 329
    - input: { nums: [7, 9, 8, 0, 7, 1, 3, 5, 5, 2, 3] }
      expected: 1654
    - input: { nums: [35, 16, 83, 87, 32, 45, 81, 26] }
      expected: 1310780
    - input: { nums: [8, 2, 6, 8, 9, 8, 1, 4, 1, 5, 3, 0, 7, 7, 0, 4, 2, 2, 5] }
      expected: 3630

description: |
  You are given `n` balloons, indexed from `0` to `n - 1`. Each balloon is painted with a number on it represented by an array `nums`. You are asked to burst all the balloons.

  If you burst the i<sup>th</sup> balloon, you will get `nums[i - 1] * nums[i] * nums[i + 1]` coins. If `i - 1` or `i + 1` goes out of bounds of the array, then treat it as if there is a balloon with a `1` painted on it.

  Return *the maximum coins you can collect by bursting the balloons wisely*.

constraints: |
  - `n == nums.length`
  - `1 <= n <= 300`
  - `0 <= nums[i] <= 100`

examples:
  - input: "nums = [3,1,5,8]"
    output: "167"
    explanation: "nums = [3,1,5,8] → [3,5,8] → [3,8] → [8] → []. coins = 3×1×5 + 3×5×8 + 1×3×8 + 1×8×1 = 15 + 120 + 24 + 8 = 167"
  - input: "nums = [1,5]"
    output: "10"
    explanation: "We can burst balloon 0 first (1×1×5 = 5) then balloon 1 (1×5×1 = 5), or burst balloon 1 first (1×5×1 = 5) then balloon 0 (1×1×1 = 1). The first order gives 10 coins total."

explanation:
  intuition: |
    This problem seems straightforward at first — just try all possible orders and pick the best one. But with `n` balloons, there are `n!` possible orders, making brute force infeasible.

    The key insight comes from **thinking backwards**. Instead of asking "which balloon do I burst first?", ask "which balloon do I burst *last* in a given range?"

    Imagine you have a subarray of balloons between indices `left` and `right`. If you decide that balloon `k` will be the **last balloon burst** in this range, something magical happens: when balloon `k` is finally burst, all other balloons between `left` and `right` are already gone. This means the coins you get for bursting `k` are simply `nums[left-1] * nums[k] * nums[right+1]` — the balloons at the boundaries of the range.

    Think of it like this: you're building up the solution from the end state backwards. By fixing which balloon is burst last, you divide the problem into two independent subproblems — the balloons to the left of `k` and the balloons to the right of `k`. The subproblems are independent because when you burst balloons in the left portion, balloon `k` still exists as a boundary; same for the right portion.

    This **interval DP** pattern transforms an exponential problem into a polynomial one by cleverly reframing the question.

  approach: |
    We solve this using **Interval Dynamic Programming**:

    **Step 1: Pad the array with boundary values**

    - Create a new array with `1` at both ends: `[1] + nums + [1]`
    - This handles the boundary condition where out-of-bounds neighbours are treated as `1`
    - The new array has length `n + 2`

    &nbsp;

    **Step 2: Define the DP state**

    - `dp[left][right]`: Maximum coins obtainable by bursting all balloons in the **open interval** `(left, right)` — that is, balloons at indices `left+1` to `right-1`
    - We use open intervals so the boundaries `left` and `right` represent virtual "walls" that haven't been burst yet

    &nbsp;

    **Step 3: Fill the DP table by interval length**

    - Start with intervals of length 2 (no balloons inside) — these are base cases with value `0`
    - For each interval length from 3 to `n + 2`:
      - For each starting position `left`:
        - Try each balloon `k` in `(left, right)` as the **last balloon to burst**
        - When `k` is burst last, it sees `nums[left]` and `nums[right]` as neighbours
        - Coins = `dp[left][k]` + `nums[left] * nums[k] * nums[right]` + `dp[k][right]`
        - Take the maximum over all choices of `k`

    &nbsp;

    **Step 4: Return the result**

    - Return `dp[0][n+1]`, which represents bursting all balloons in the original array

  common_pitfalls:
    - title: Thinking Forward Instead of Backward
      description: |
        The natural instinct is to simulate bursting balloons in order: "I burst balloon 2, now the array changes, then I burst balloon 0..." This leads to a complex state space where the order of previous bursts affects what neighbours exist.

        The breakthrough is to think about which balloon is burst **last** in a range. When it's burst last, its neighbours are fixed — they're the boundaries of the range. This transforms a complex ordering problem into a clean interval DP.
      wrong_approach: "Simulate bursting order and track array changes"
      correct_approach: "Fix the last-burst balloon to create independent subproblems"

    - title: Forgetting to Pad the Array
      description: |
        The problem states that out-of-bounds neighbours are treated as `1`. If you forget to pad the array with `1`s at both ends, your boundary calculations will be wrong.

        With padding `[1] + nums + [1]`, the indices work cleanly: the original balloons are at indices `1` to `n`, and the virtual boundaries at `0` and `n+1` provide the correct multipliers.
      wrong_approach: "Handle boundaries with special case logic"
      correct_approach: "Pad array with 1s so boundaries are handled uniformly"

    - title: Wrong Interval Iteration Order
      description: |
        The DP recurrence `dp[left][right] = max(dp[left][k] + ... + dp[k][right])` requires that smaller intervals are computed before larger ones.

        If you iterate in the wrong order (e.g., left-to-right, then top-to-bottom), you'll reference DP values that haven't been computed yet. You must iterate by **interval length** from smallest to largest.
      wrong_approach: "Iterate by row then column in DP table"
      correct_approach: "Iterate by interval length from 2 to n+2"

  key_takeaways:
    - "**Interval DP pattern**: When subproblems overlap based on ranges, define `dp[left][right]` for intervals and iterate by length"
    - "**Think backwards**: Asking 'what happens last?' can transform a complex ordering problem into manageable subproblems"
    - "**Boundary padding**: Adding sentinel values at array boundaries often simplifies edge case handling"
    - "**Foundation for harder problems**: This interval DP technique appears in problems like Matrix Chain Multiplication, Minimum Cost to Merge Stones, and Palindrome Partitioning"

  time_complexity: "O(n^3). We have O(n^2) intervals, and for each interval we try O(n) choices for the last balloon to burst."
  space_complexity: "O(n^2). We use a 2D DP table of size `(n+2) × (n+2)` to store the maximum coins for each interval."

solutions:
  - approach_name: Interval DP (Bottom-Up)
    is_optimal: true
    code: |
      def max_coins(nums: list[int]) -> int:
          # Pad the array with 1s at both ends for boundary handling
          nums = [1] + nums + [1]
          n = len(nums)

          # dp[left][right] = max coins from bursting all balloons in (left, right)
          dp = [[0] * n for _ in range(n)]

          # Iterate by interval length (gap between left and right)
          for length in range(2, n):  # length 2 means no balloons inside
              for left in range(n - length):
                  right = left + length

                  # Try each balloon k as the last one to burst in this interval
                  for k in range(left + 1, right):
                      # Coins from bursting k last: left and right are its neighbours
                      coins = nums[left] * nums[k] * nums[right]
                      # Add coins from left subproblem and right subproblem
                      total = dp[left][k] + coins + dp[k][right]
                      dp[left][right] = max(dp[left][right], total)

          # Result is for interval (0, n-1) which covers all original balloons
          return dp[0][n - 1]
    explanation: |
      **Time Complexity:** O(n^3) — Three nested loops: O(n) for length, O(n) for left position, O(n) for choice of k.

      **Space Complexity:** O(n^2) — The 2D DP table stores results for all intervals.

      We iterate by interval length to ensure smaller subproblems are solved before larger ones. For each interval, we try every balloon as the "last to burst" and take the maximum. The key insight is that when balloon k is burst last in interval (left, right), its neighbours are exactly nums[left] and nums[right].

  - approach_name: Memoised Recursion (Top-Down)
    is_optimal: true
    code: |
      def max_coins(nums: list[int]) -> int:
          # Pad array with boundary 1s
          nums = [1] + nums + [1]
          n = len(nums)

          # Memoization cache
          memo = {}

          def dp(left: int, right: int) -> int:
              # Base case: no balloons in the open interval
              if right - left < 2:
                  return 0

              if (left, right) in memo:
                  return memo[(left, right)]

              result = 0
              # Try each balloon k as the last to burst in (left, right)
              for k in range(left + 1, right):
                  # k sees left and right as neighbours when burst last
                  coins = nums[left] * nums[k] * nums[right]
                  total = dp(left, k) + coins + dp(k, right)
                  result = max(result, total)

              memo[(left, right)] = result
              return result

          return dp(0, n - 1)
    explanation: |
      **Time Complexity:** O(n^3) — Same as bottom-up; each of O(n^2) states computed once with O(n) work.

      **Space Complexity:** O(n^2) — Memoization cache plus O(n) recursion stack.

      This top-down approach is often more intuitive. We recursively solve for the maximum coins in interval (left, right) by trying each balloon as the last to burst. Memoization ensures each subproblem is solved only once.

  - approach_name: Brute Force (Backtracking)
    is_optimal: false
    code: |
      def max_coins(nums: list[int]) -> int:
          def get_coins(arr: list[int], i: int) -> int:
              """Calculate coins for bursting balloon at index i."""
              left = arr[i - 1] if i > 0 else 1
              right = arr[i + 1] if i < len(arr) - 1 else 1
              return left * arr[i] * right

          def backtrack(arr: list[int]) -> int:
              if not arr:
                  return 0

              max_coins = 0
              # Try bursting each balloon
              for i in range(len(arr)):
                  # Coins from bursting this balloon
                  coins = get_coins(arr, i)
                  # Recurse on remaining balloons
                  remaining = arr[:i] + arr[i + 1:]
                  total = coins + backtrack(remaining)
                  max_coins = max(max_coins, total)

              return max_coins

          return backtrack(nums)
    explanation: |
      **Time Complexity:** O(n!) — We try all n! permutations of bursting order.

      **Space Complexity:** O(n^2) — Recursion depth O(n) with O(n) array copies at each level.

      This brute force approach tries every possible order of bursting balloons. While correct, it's far too slow for the constraint n ≤ 300. It's included to illustrate why the interval DP insight is essential — we go from factorial time to polynomial time.
