Lesson 8.63,737 words

Interval DP

Many problems ask for the best way to combine a contiguous range of items, and the answer is a dynamic program over subintervals [i,j][i,j] that chooses a split point kk. We derive the pattern from matrix-chain multiplication — parenthesising a product to minimize scalar multiplications in O(n3)O(n^3) — distil it into a reusable template filled by increasing interval length, and then meet its sharpest variant: the "last operation" trick behind Burst Balloons and cutting a stick, where fixing the last move (not the first) makes the two sides independent.

╌╌╌╌

The sequence DPs we have met so far walked a string or array from one end and let the subproblem be a prefix: the state was the best answer using the first items. A second, equally common family does not decompose by prefix at all. It decomposes by contiguous range. The natural subproblem is "the best answer for the slice ," and the recurrence builds a long range out of two shorter ranges by guessing where they meet: a split point inside . This is interval dynamic programming, one of the core DP patterns. The same shape appears in parenthesising a product, building an optimal search tree, bursting balloons, cutting a stick, and partitioning a string.

The archetype, and the cleanest place to learn the pattern, is the problem of parenthesising a matrix product.

Matrix-chain multiplication

Multiplying a matrix by a matrix takes scalar multiplications. Matrix multiplication is associative, so for a chain the answer is the same however we parenthesise, but the cost is not. Given a chain of matrices where has dimensions (so the dimension sequence is ), we want the parenthesisation that minimizes the total number of scalar multiplications.1

The number of parenthesisations grows like the Catalan numbers, exponentially, so brute force is hopeless. But the problem has the two features every DP needs.

The other ingredient is overlapping subproblems: both sides are again contiguous subchains, and the same subchain is reached by many different outer choices, so a table of subchains is solved once each.

Let be the minimum number of scalar multiplications needed to compute . A single matrix needs no multiplications, and otherwise we try every split:

The term is the cost of the final multiplication: the left block is a matrix, the right block is , and combining them costs .

Combine two solved subintervals at a split ; the chosen split is highlighted

Filling the table

The recurrence for depends only on intervals strictly shorter than (the left side has length , the right side ). So if we fill the table in order of increasing interval length , every value we read is already computed. We also record, in a split table , the that achieved the minimum, so we can reconstruct the parenthesisation afterwards.

Algorithm:Matrix-Chain-Order(p)\textsc{Matrix-Chain-Order}(p) — fill mm, ss by increasing chain length
  1. 1
    nlength(p)1n \gets \text{length}(p) - 1
  2. 2
    for i1i \gets 1 to nn do
  3. 3
    m[i,i]0m[i,i] \gets 0
  4. 4
    for 2\ell \gets 2 to nn do
    \ell = chain length
  5. 5
    for i1i \gets 1 to n+1n - \ell + 1 do
  6. 6
    ji+1j \gets i + \ell - 1
  7. 7
    m[i,j]m[i,j] \gets \infty
  8. 8
    for kik \gets i to j1j - 1 do
    try every split
  9. 9
    qm[i,k]+m[k+1,j]+pi1pkpjq \gets m[i,k] + m[k+1,j] + p_{i-1}\cdot p_k \cdot p_j
  10. 10
    if q<m[i,j]q < m[i,j] then
  11. 11
    m[i,j]qm[i,j] \gets q
  12. 12
    s[i,j]ks[i,j] \gets k
  13. 13
    return m, sm,\ s

There are entries and each costs to fill (the inner loop over ), so the algorithm runs in time and space. The answer is ; the optimal parenthesisation is read back from by recursing: gives the outermost split, then and give the next ones, and so on down to single matrices.

Optimal split of at ; each node shows its interval cost , dims
The upper-triangular table, filled along diagonals of increasing length; cell depends on its row to the left and its column below

The diagonal (length ) is the base case; each successive diagonal moving toward the top-right corner holds longer intervals, and cell draws on cells in its own row to the left (the terms) and its own column below (the terms). That dependency shape, left along the row and down the column, is the signature of an interval DP.

To make the fill concrete, take the four-matrix chain with dimension sequence , so is , is , is , is . The base diagonal is all zeros. The length- diagonal has a single split each:

The length- diagonal tries two splits each and keeps the cheaper:

Finally the length- interval tries all three splits, and wins with . The completed table and its split choices:

Completed table for (blanks are the unused lower triangle); each cell holds the minimum cost, and the arrow marks the increasing-length diagonal fill order ending at the answer .
matrix_chain_order.pypython
from typing import NamedTuple, Sequence

class ChainSolution(NamedTuple):
  """
    The optimal cost of a matrix chain plus its parenthesisation string.\n
  """
  cost: int
  parenthesisation: str

def matrix_chain_cost(dimensions: Sequence[int]) -> int:
  """
    Fewest scalar multiplications to evaluate the chain whose dimension\n
    sequence is `dimensions` (length n+1 for n matrices). An empty chain\n
    (fewer than two dimensions) costs 0.\n
  """
  matrix_count: int = len(dimensions) - 1
  if matrix_count <= 1:
    return 0

  # min_cost[i][j] = best cost for the subchain A_i...A_j (1-indexed matrices).
  min_cost: list[list[int]] = [
    [0 for _ in range(matrix_count + 1)] for _ in range(matrix_count + 1)
  ]

  # fill by increasing chain length so shorter subchains are ready first.
  for length in range(2, matrix_count + 1):
    for start in range(1, matrix_count - length + 2):
      end: int = start + length - 1

      # try every split k, combining the two halves at cost p_{i-1} p_k p_j.
      best: int = -1
      for split in range(start, end):
        combine: int = (
          dimensions[start - 1] * dimensions[split] * dimensions[end]
        )
        candidate: int = min_cost[start][split] + min_cost[split + 1][end] + combine
        if best < 0 or candidate < best:
          best = candidate
      min_cost[start][end] = best

  return min_cost[1][matrix_count]

def matrix_chain_order(dimensions: Sequence[int]) -> ChainSolution:
  """
    Optimal cost and a fully parenthesised expression (matrices named\n
    A1, A2, ...) for the chain with the given `dimensions`.\n
  """
  matrix_count: int = len(dimensions) - 1
  if matrix_count <= 0:
    return ChainSolution(0, "")
  if matrix_count == 1:
    return ChainSolution(0, "A1")

  # min_cost holds the costs; split_at[i][j] records the winning split k.
  min_cost: list[list[int]] = [
    [0 for _ in range(matrix_count + 1)] for _ in range(matrix_count + 1)
  ]
  split_at: list[list[int]] = [
    [0 for _ in range(matrix_count + 1)] for _ in range(matrix_count + 1)
  ]

  # fill by increasing chain length, remembering each interval's best split.
  for length in range(2, matrix_count + 1):
    for start in range(1, matrix_count - length + 2):
      end: int = start + length - 1

      # try every split k and keep the cheapest along with its k.
      best: int = -1
      best_split: int = start
      for split in range(start, end):
        combine: int = (
          dimensions[start - 1] * dimensions[split] * dimensions[end]
        )
        candidate: int = min_cost[start][split] + min_cost[split + 1][end] + combine
        if best < 0 or candidate < best:
          best = candidate
          best_split = split

      min_cost[start][end] = best
      split_at[start][end] = best_split

  def build(start: int, end: int) -> str:
    """
      Reconstruct the parenthesised expression for A_start...A_end.\n
    """
    if start == end:
      return f"A{start}"

    # recurse on the two halves at the recorded split point.
    split: int = split_at[start][end]
    return f"({build(start, split)}{build(split + 1, end)})"

  return ChainSolution(min_cost[1][matrix_count], build(1, matrix_count))

The interval-DP recipe

Strip matrix-chain of its specifics and a reusable pattern remains.

The art in any specific problem is steps 1 and 3: defining the slice so that the two sides really are independent, and finding the cost term that depends only on the endpoints and the split. The rest is bookkeeping.

Optimal binary search tree

A first variation keeps the split idea but changes what the cost term measures. Given sorted keys with search probabilities , an optimal binary search tree is the BST minimizing the expected search cost . Choosing key as the root of the subtree on keys splits the rest into a left subtree on and a right subtree on , again two independent ranges combined at a pivot. Making the root pushes every key in down one level, which adds the total weight to the cost:

Picking root hangs the two solved subtrees beneath it, and the act of adding a root pushes every key in one level deeper, so its whole weight is charged once more:

Root on keys : the optimal subtrees on and hang below, and rooting adds one level to all keys, charging .

The shape is identical to matrix-chain, with the same diagonals and the same fill, the combine term now being the swept-down weight of the whole interval rather than a product of dimensions.2

optimal_bst.pypython
from typing import Optional, Sequence

class BSTNode:
  """
    A node of a reconstructed optimal BST: the key index it holds plus its\n
    left and right children (None at the leaves).\n
  """

  def __init__(self, key_index: int) -> None:
    self.key_index: int = key_index
    self.left: Optional[BSTNode] = None
    self.right: Optional[BSTNode] = None

def optimal_bst_cost(weights: Sequence[float]) -> float:
  """
    Minimum expected search cost over a tree on keys with the given\n
    `weights` (probabilities or frequencies), ordered by key. An empty\n
    sequence costs 0.\n
  """
  key_count: int = len(weights)
  if key_count == 0:
    return 0.0

  # prefix[r] = sum of the first r weights, for O(1) interval weight queries.
  prefix: list[float] = [0.0 for _ in range(key_count + 1)]
  for index in range(key_count):
    prefix[index + 1] = prefix[index] + weights[index]

  def interval_weight(low: int, high: int) -> float:
    return prefix[high + 1] - prefix[low]

  # cost[i][j] = best expected cost for the keys [i, j]; empty range is 0.
  cost: list[list[float]] = [[0.0 for _ in range(key_count)] for _ in range(key_count + 1)]

  # fill by increasing interval length so subtrees are ready first.
  for length in range(1, key_count + 1):
    for low in range(0, key_count - length + 1):
      high: int = low + length - 1
      weight: float = interval_weight(low, high)

      # try each key as root; rooting charges the interval weight once more.
      best: float = -1.0
      for root in range(low, high + 1):
        left: float = cost[low][root - 1] if root > low else 0.0
        right: float = cost[root + 1][high] if root < high else 0.0
        candidate: float = left + right + weight
        if best < 0 or candidate < best:
          best = candidate
      cost[low][high] = best

  return cost[0][key_count - 1]

def optimal_bst(weights: Sequence[float]) -> tuple[float, Optional[BSTNode]]:
  """
    Minimum expected cost and the root of an optimal BST (a tree of BSTNode\n
    objects keyed by index) for the given `weights`. Returns (0, None) for\n
    an empty sequence.\n
  """
  key_count: int = len(weights)
  if key_count == 0:
    return 0.0, None

  # prefix[r] = sum of the first r weights, for O(1) interval weight queries.
  prefix: list[float] = [0.0 for _ in range(key_count + 1)]
  for index in range(key_count):
    prefix[index + 1] = prefix[index] + weights[index]

  def interval_weight(low: int, high: int) -> float:
    return prefix[high + 1] - prefix[low]

  # cost holds the expected costs; root_of[i][j] records the chosen root.
  cost: list[list[float]] = [[0.0 for _ in range(key_count)] for _ in range(key_count + 1)]
  root_of: list[list[int]] = [[0 for _ in range(key_count)] for _ in range(key_count)]

  # fill by increasing interval length, remembering each interval's best root.
  for length in range(1, key_count + 1):
    for low in range(0, key_count - length + 1):
      high: int = low + length - 1
      weight: float = interval_weight(low, high)

      # try each key as root and keep the cheapest along with its index.
      best: float = -1.0
      best_root: int = low
      for root in range(low, high + 1):
        left: float = cost[low][root - 1] if root > low else 0.0
        right: float = cost[root + 1][high] if root < high else 0.0
        candidate: float = left + right + weight
        if best < 0 or candidate < best:
          best = candidate
          best_root = root

      cost[low][high] = best
      root_of[low][high] = best_root

  def build(low: int, high: int) -> Optional[BSTNode]:
    """
      Reconstruct the optimal subtree on keys [low, high].\n
    """
    if low > high:
      return None

    # hang the recorded subtrees beneath the chosen root.
    root: int = root_of[low][high]
    node: BSTNode = BSTNode(root)
    node.left = build(low, root - 1)
    node.right = build(root + 1, high)
    return node

  return cost[0][key_count - 1], build(0, key_count - 1)

The last operation trick: Burst Balloons and cutting a stick

Some problems resist the naive split because the cost of a piece depends on what is adjacent to it, and the first split severs the very adjacency that sets the cost. To address this, guess the last operation instead of the first.

Take Burst Balloons: balloons have values , and bursting balloon earns where left and right are its current surviving neighbors; bursting removes and rejoins the neighbors. We want the maximum total earnings. If we try to fix the first balloon burst in , the two halves are not independent: a balloon in the left half can later have a right neighbor in the right half, so the halves keep interacting through the shared boundary.

Now fix the balloon that is burst last in the open interval (using sentinels just outside the range that are never burst). When is burst last, every other balloon in is already gone, so its two neighbors at that moment can only be the boundaries and . The earnings for that final burst are , fixed and independent of order; the balloons in were all burst before while and remained fixed boundaries, and likewise against and . The two sides are therefore independent:

Bursting last in : every other balloon is already gone, so 's neighbors are pinned to the walls and earn

Minimum Cost to Cut a Stick is the same idea in dual form. A stick has cut positions inside it; making a cut costs the current length of the piece being cut. Fix which cut in is performed last: at that moment the piece runs uncut from wall to wall , so the cost equals the length , fixed; and the cuts in and were made earlier, each within its own sub-piece, independently. Same recurrence, over the cut positions. The lesson generalizes: when the per-step cost depends on neighbors, ask which step is last, since the last step is the one whose context is fully determined by the interval endpoints.

burst_balloons.pypython
from typing import Sequence

def burst_balloons(values: Sequence[int]) -> int:
  """
    Maximum coins earned by bursting every balloon in `values`. An empty\n
    sequence earns 0.\n
  """
  if not values:
    return 0

  # Pad with sentinel 1s so the walls just outside the range are never burst.
  padded: list[int] = [1, *values, 1]
  wall_count: int = len(padded)

  # best[left][right] = max coins from bursting all balloons strictly inside
  # the open interval (left, right), with left and right standing as walls.
  best: list[list[int]] = [[0 for _ in range(wall_count)] for _ in range(wall_count)]

  # fill by increasing interval span so inner intervals are solved first.
  for length in range(2, wall_count):
    for left in range(0, wall_count - length):
      right: int = left + length

      # fix the balloon burst last; its neighbors are pinned to the walls.
      most: int = 0
      for last in range(left + 1, right):
        coins: int = padded[left] * padded[last] * padded[right]
        candidate: int = best[left][last] + best[last][right] + coins
        most = max(most, candidate)
      best[left][right] = most

  return best[0][wall_count - 1]
min_cost_cut_stick.pypython
from typing import Sequence

def min_cost_cut_stick(stick_length: int, cuts: Sequence[int]) -> int:
  """
    Minimum total cost to make every cut in `cuts` on a stick of length\n
    `stick_length`. With no cuts the cost is 0.\n
  """
  if not cuts:
    return 0

  # Walls = the two ends plus every cut position, sorted; the endpoints of a
  # sub-piece are always wall positions, so a "last" cut runs wall to wall.
  positions: list[int] = sorted({0, stick_length, *cuts})
  wall_count: int = len(positions)

  # best[low][high] = min cost to make all cuts strictly between walls
  # low and high (exclusive).
  best: list[list[int]] = [[0 for _ in range(wall_count)] for _ in range(wall_count)]

  # fill by increasing sub-piece span so inner pieces are solved first.
  for length in range(2, wall_count):
    for low in range(0, wall_count - length):
      high: int = low + length
      piece_length: int = positions[high] - positions[low]

      # fix the last cut; its piece spans wall to wall at a flat cost.
      cheapest: int = -1
      for last in range(low + 1, high):
        candidate: int = best[low][last] + best[last][high] + piece_length
        if cheapest < 0 or candidate < cheapest:
          cheapest = candidate
      best[low][high] = max(cheapest, 0)

  return best[0][wall_count - 1]

Palindrome partitioning over a string

Interval DP also runs over strings. In Palindrome Partitioning II we cut a string into pieces that are each palindromes, minimizing the number of cuts. Precompute , whether is a palindrome, itself an interval DP, since is a palindrome iff and is (a length- shorter interval). Then let be the fewest cuts for the prefix ; for each we look back to the last palindromic piece:

The palindrome table is the interval DP (filled by increasing length); the cut count is then a sweep over it, a clean example of one interval DP feeding a second, simpler one.

palindrome_partitioning.pypython
def palindrome_table(text: str) -> list[list[bool]]:
  """
    is_palindrome[i][j] for every substring text[i..j], filled by\n
    increasing interval length.\n
  """
  length: int = len(text)

  # every single character is a palindrome on its own.
  is_palindrome: list[list[bool]] = [
    [False for _ in range(length)] for _ in range(length)
  ]
  for start in range(length):
    is_palindrome[start][start] = True

  # grow by span: text[i..j] holds iff its ends match and the inside does.
  for span in range(2, length + 1):
    for start in range(0, length - span + 1):
      end: int = start + span - 1
      ends_match: bool = text[start] == text[end]
      inner_ok: bool = span == 2 or is_palindrome[start + 1][end - 1]
      is_palindrome[start][end] = ends_match and inner_ok

  return is_palindrome

def min_palindrome_cuts(text: str) -> int:
  """
    Fewest cuts so every piece of `text` is a palindrome. The empty string\n
    and any palindrome need 0 cuts.\n
  """
  length: int = len(text)
  if length <= 1:
    return 0

  is_palindrome: list[list[bool]] = palindrome_table(text)

  # cut_count[end] = fewest cuts for the prefix text[0..end].
  cut_count: list[int] = [0 for _ in range(length)]
  for end in range(length):

    # a palindromic prefix needs no cuts at all.
    if is_palindrome[0][end]:
      cut_count[end] = 0
      continue

    # else cut before the last palindromic piece, minimizing over its start.
    best: int = end  # worst case: cut before every character.
    for start in range(1, end + 1):
      if is_palindrome[start][end]:
        candidate: int = cut_count[start - 1] + 1
        if candidate < best:
          best = candidate
    cut_count[end] = best

  return cut_count[length - 1]

When is too slow

The cost comes from the inner scan over all splits . For a class of interval DPs (matrix-chain, optimal BST, and others whose cost obeys the quadrangle inequality) the optimal split point is monotone in the endpoints, so the search for can be confined to a shrinking window. This is Knuth's optimization, and it drops the running time to . We treat the conditions and the proof in the lesson on DP optimizations; for now, note only that the here can sometimes be improved.3

Where interval DP leads

Matrix-chain multiplication is the textbook instance of a deeper question: the DP here finds the cheapest binary parenthesization, but the true optimum over all associativity trees was shown by Hu and Shing (1982, 1984, SIAM J. Computing) to be computable in by reducing matrix-chain to a problem of triangulating a convex polygon — a drop from cubic that the DP formulation does not suggest. The polygon-triangulation view generalizes: any interval DP with a split point is a DP over triangulations, equivalently over binary trees on the interval, which is why the number of states matches the Catalan numbers.4

Optimal binary search trees have a parallel history. Knuth's 1971 algorithm (via the monotone-root property, the DP-optimizations lesson) is exact; Mehlhorn's 1975 result showed a simple greedy near-optimal BST — always root at the key that balances the weight — comes within a constant factor of optimal in time, the sort of good enough, much faster trade that recurs whenever the exact DP is a bottleneck. The same static-optimality question, asked online, produced splay trees (Sleator and Tarjan, 1985) and the still-open dynamic optimality conjecture.

The last operation decoupling behind Burst Balloons is a reusable modeling move: the same reframing underlies CYK parsing. Parsing a context-free grammar asks for the best derivation of a substring , and the split point is the position where the top-level rule divides the span — an interval DP whose combine multiplies sub-derivation probabilities, giving probabilistic parsing (the foundation of pre-neural natural-language parsing) and, over the Boolean semiring, CFG recognition (Cocke, Younger, Kasami, 1960s). RNA secondary-structure prediction (Nussinov, 1978; Zuker, 1981) is the same interval DP again: fold a strand by choosing which base pairs with position , splitting the loop into independent inner and outer intervals. Interval DP, the polygon triangulation, CYK parsing, and RNA folding are the same recurrence in four settings.

Takeaways

  • Interval DP solves problems over a contiguous range: the state is a slice , the recurrence picks a split or pivot that breaks the range into two independent parts, and the table is filled by increasing interval length so both parts are ready, typically states splits .
  • Matrix-chain multiplication is the archetype: , , with a split table to reconstruct the parenthesisation in time and space.
  • Optimal BSTs reuse the same shape, replacing the combine term with the swept weight of the interval.
  • The last operation trick (Burst Balloons, Minimum Cost to Cut a Stick) fixes which move happens last in , not first, pinning the moving neighbor cost to the fixed interval walls and making the two sides truly independent.
  • Palindrome Partitioning II runs interval DP over a string: a palindrome table by increasing length, then a linear cut-count sweep.
  • Knuth's optimization cuts matrix-chain-style DPs to when the quadrangle inequality makes the optimal split monotone — see the DP-optimizations lesson.

Footnotes

  1. CLRS, Ch. 15 — Dynamic Programming (§15.2): matrix-chain multiplication, the recurrence, and reconstruction from the split table in .
  2. CLRS, Ch. 15 — Dynamic Programming (§15.5): optimal binary search trees as the same interval DP with the interval-weight combine term.
  3. Skiena, § — Dynamic Programming: interval DPs as range subproblems combined at a split, and when monotonicity (Knuth) lowers the cubic cost.
  4. Hu & Shing (1982/1984, SIAM J. Computing): optimal matrix-chain parenthesization in via convex-polygon triangulation. Mehlhorn (1975): near-optimal BSTs greedily in . Nussinov (1978) and Zuker (1981): RNA secondary-structure folding as interval DP; the same recurrence underlies CYK CFG parsing.
Practice

╌╌ END ╌╌