Dynamic Programming/Longest Increasing Subsequence

Lesson 8.33,382 words

Longest Increasing Subsequence

Given a sequence of numbers, how long is its longest strictly increasing subsequence? A first dynamic program indexes subproblems by the element each subsequence ends at, giving an O(n2)O(n^2) solution with parent-pointer reconstruction.

╌╌╌╌

The previous lesson aligned two sequences. Now we ask a question about a single one. Given an array of numbers, a longest increasing subsequence (LIS) is a longest set of positions whose values strictly increase, . As with LCS, the word is subsequence, not substring: the chosen elements keep their original order but need not be contiguous. In the subsequence increases and has length , and no longer one exists, so the LIS length is .1

Plot the values against their positions and an LIS is a longest chain of points that climbs as it moves right, never stepping down:

Values of plotted against position. An LIS is a longest left-to-right chain that strictly rises; the highlighted path (length ) is one such chain.

A brute-force scan over all subsequences is hopeless. But LIS has clean optimal substructure, the same property behind every dynamic program, and it admits two algorithms worth knowing well: a direct dynamic program, and a faster method built on patience sorting.

The dynamic program: subsequences that end here

The decisive modeling move, the analog of LCS's index by prefix, is to index each subproblem by the element its subsequence is forced to end at. Anchoring the endpoint is what makes the pieces compose: an increasing subsequence ending at is some shorter increasing subsequence ending at an earlier, smaller element, with tacked on.

Every increasing subsequence ends somewhere, so the answer is , not , a small but important distinction from the prefix DPs, where the answer sat in the last cell.

To extend a subsequence so that it ends at , look at all earlier indices whose value is smaller than ; any increasing subsequence ending at such a can be lengthened by appending . We take the best such predecessor, or start fresh with just if none exists:

The guarantees the is defined and yields when no smaller predecessor exists: the subsequence consisting of alone.

Each scans the earlier indices, so the fill is time and space.

Algorithm 1:LIS-Quadratic(a[1..n])\textsc{LIS-Quadratic}(a[1..n]) — length and parent pointers
  1. 1
    for i1i \gets 1 to nn do
  2. 2
    L[i]1L[i] \gets 1 ;  prev[i]nil\ \mathit{prev}[i] \gets \text{nil}
    singleton
  3. 3
    for j1j \gets 1 to i1i - 1 do
  4. 4
    if a[j]<a[i]a[j] < a[i] and L[j]+1>L[i]L[j] + 1 > L[i] then
  5. 5
    L[i]L[j]+1L[i] \gets L[j] + 1
  6. 6
    prev[i]j\mathit{prev}[i] \gets j
    best predecessor
  7. 7
    bestargmaxiL[i]\mathit{best} \gets \arg\max_i L[i]
  8. 8
    return L[best]L[\mathit{best}] and the chain best,prev[best],\mathit{best}, \mathit{prev}[\mathit{best}], \dots

Reconstruction. The array is a forest of parent pointers: to recover an actual LIS, find the index maximizing , then follow backwards until it hits nil, reversing the collected indices. This costs , cheap beside the fill.

Parent pointers for . Following back from the best endpoint (index , ) yields .

The blue endpoints form the recovered chain: starting at the best cell (index , value ) and hopping along visits indices , whose values reverse to the LIS .

One chain (indices ), arrows along the cell tops. The dotted arrows show equally-long alternatives — starting at the (index ), or ending at the (index ) instead of the — so the LIS is not unique.

The solid chain (indices ) realizes at index , and the row beneath records every . A subtlety: the predecessor of is not unique — both the (index ) and the (index ) are smaller and end a length- run, so either could precede it. Reconstruction is greedy: it stores and follows a single parent, and we take the earlier index, giving . The dotted arrows mark the other choices: extending the at index instead yields , and stopping at the (index , also ) yields . Whenever has ties or several endpoints reach the maximum length, the LIS is simply not unique.

lis_quadratic.pypython
from typing import Optional, Sequence, TypeVar

from comparable import Comparable

# any totally-ordered element works (ints, floats, strings, tuples).
Value = TypeVar("Value", bound=Comparable)

def lis_length(values: Sequence[Value], strict: bool = True) -> int:
  """
    Length of a longest increasing subsequence of `values`.\n
    With `strict` False, equal adjacent values are allowed (longest\n
    non-decreasing subsequence).\n
  """
  return len(lis_subsequence(values, strict=strict))

def lis_subsequence(
  values: Sequence[Value], strict: bool = True
) -> list[Value]:
  """
    An actual longest increasing subsequence of `values`, reconstructed by\n
    following parent pointers from the best endpoint. Ties are broken toward\n
    the earlier predecessor, matching the lesson's greedy reconstruction.\n
    With `strict` False, the run may be non-decreasing.\n
  """
  count: int = len(values)
  if count == 0:
    return []

  # length[i] = longest run ending exactly at i; parent[i] = its predecessor.
  length: list[int] = [1 for _ in range(count)]
  parent: list[Optional[int]] = [None for _ in range(count)]

  for index in range(count):
    for earlier in range(index):

      # non-strict `earlier <= index` as `not (index < earlier)`, so the
      # element only needs `<` (the Comparable protocol bound on Value).
      precedes: bool = (
        values[earlier] < values[index]
        if strict
        else not (values[index] < values[earlier])
      )

      # extend through `earlier` when it yields a strictly longer run.
      if precedes and length[earlier] + 1 > length[index]:
        length[index] = length[earlier] + 1
        parent[index] = earlier

  # best endpoint is the cell with the greatest run length.
  best_endpoint: int = max(range(count), key=lambda index: length[index])

  # walk parent pointers back, then reverse into forward order.
  chain: list[Value] = []
  cursor: Optional[int] = best_endpoint
  while cursor is not None:
    chain.append(values[cursor])
    cursor = parent[cursor]

  chain.reverse()
  return chain
comparable.pypython
from typing import Any, Protocol, TypeVar


class Comparable(Protocol):
  """
    Anything orderable with `<` (int, float, str, tuple, date, …).\n
  """

  # `other` is position-only so built-ins (int, str, …), whose dunder
  # operands are position-only, structurally satisfy the protocol.
  def __lt__(self, other: Any, /) -> bool: ...
  def __gt__(self, other: Any, /) -> bool: ...
  def __le__(self, other: Any, /) -> bool: ...
  def __ge__(self, other: Any, /) -> bool: ...

The method: patience sorting and the tails array

The quadratic inner loop searches all earlier indices for the best predecessor. To remove it, stop tracking endpoints individually and instead maintain, for each achievable length, the single most useful witness.

The whole algorithm is one pass. For each element , binary-search for the first entry (a lower_bound). If one is found, overwrite it with ; if none is (so exceeds every tail), append . The LIS length is the final length of .

Algorithm 2:LIS-Patience(a[1..n])\textsc{LIS-Patience}(a[1..n]) — tails array, O(nlogn)O(n\log n)
  1. 1
    tails\mathit{tails} \gets empty array
  2. 2
    for i1i \gets 1 to nn do
  3. 3
    kLower-Bound(tails, a[i])k \gets \textsc{Lower-Bound}(\mathit{tails},\ a[i])
    first tails[k]a[i]\mathit{tails}[k] \ge a[i]
  4. 4
    if k=tailsk = |\mathit{tails}| then
  5. 5
    append a[i]a[i] to tails\mathit{tails}
    exceeds all tails: extend
  6. 6
    else
  7. 7
    tails[k]a[i]\mathit{tails}[k] \gets a[i]
    shrink length-(k+1)(k{+}1) tail
  8. 8
    return tails|\mathit{tails}|

Each element costs one binary search, for total.

Why this is correct

Two facts carry the whole proof. First, is always sorted in increasing order, so binary search is valid. Second, the updates never reduce any achievable length, so the array's length tracks the true LIS.

Patience sorting on : the full array as each element arrives. Blue marks the cell just written; the right column records whether the step appended (extended the LIS) or overwrote (shrank a tail). The final length is the LIS length.

Each row is after one more element, with the just-written cell in blue and the append-or-overwrite verdict on the right. Adding overwrites the (the first tail ): the length- run now ends at the smaller value , leaving room for future growth without shortening the array. The final has length , matching the LIS length. The values in that last row are not themselves an LIS — never occurs in order — but their count matches the LIS length, and the index-tracking below recovers a genuine subsequence.

The one non-trivial step in each iteration is the placement itself: given the sorted array and the incoming , find the first entry . Because is sorted, this lower_bound is a textbook binary search, halving the live window each probe. Adding into runs as follows.

Binary search (lower_bound) placing into the sorted . The window halves each probe until it collapses on index (value ), the first tail ; that cell is overwritten by .

The search never inspects the whole array: the first probe rules out the right half, so index is confirmed as the target after comparisons. Overwriting the with the smaller keeps the length- tail as small as possible.

Reconstruction in . As written, holds values, not positions, so it loses the actual subsequence. To recover it, store indices: let hold the position whose value is , and on each step record (the index then sitting one pile to the left). Following back from reconstructs an LIS, exactly as in the quadratic version.

lis_patience.pypython
from bisect import bisect_left, bisect_right
from typing import Optional, Sequence, TypeVar

from comparable import Comparable

Value = TypeVar("Value", bound=Comparable)

def lis_length_fast(values: Sequence[Value], strict: bool = True) -> int:
  """
    Length of a longest increasing subsequence in O(n log n).\n
    With `strict` True the run is strictly increasing (a `lower_bound`\n
    overwrite); with `strict` False it may be non-decreasing (an\n
    `upper_bound` overwrite, so equal elements extend rather than replace).\n
  """
  tails: list[Value] = []
  for element in values:

    # lower_bound for strict, upper_bound for non-decreasing.
    position: int = (
      bisect_left(tails, element) if strict else bisect_right(tails, element)
    )

    # append a new pile or overwrite the first tail >= element.
    if position == len(tails):
      tails.append(element)
    else:
      tails[position] = element

  return len(tails)

def lis_subsequence_fast(
  values: Sequence[Value], strict: bool = True
) -> list[Value]:
  """
    An actual longest increasing subsequence in O(n log n), recovered by\n
    storing the index that lands in each pile and a parent pointer to the\n
    index then sitting one pile to its left.\n
  """
  count: int = len(values)
  if count == 0:
    return []

  # tails holds values for the binary search; tail_index mirrors their indices.
  tails: list[Value] = []
  tail_index: list[int] = []
  parent: list[Optional[int]] = [None for _ in range(count)]

  for index in range(count):
    element: Value = values[index]
    position: int = (
      bisect_left(tails, element) if strict else bisect_right(tails, element)
    )

    # the index one pile to the left becomes this element's predecessor.
    parent[index] = tail_index[position - 1] if position > 0 else None
    if position == len(tails):
      tails.append(element)
      tail_index.append(index)
    else:
      tails[position] = element
      tail_index[position] = index

  # the last pile's index is the endpoint of a longest run.
  chain: list[Value] = []
  cursor: Optional[int] = tail_index[-1]
  while cursor is not None:
    chain.append(values[cursor])
    cursor = parent[cursor]
  chain.reverse()
  return chain
comparable.pypython
from typing import Any, Protocol, TypeVar


class Comparable(Protocol):
  """
    Anything orderable with `<` (int, float, str, tuple, date, …).\n
  """

  # `other` is position-only so built-ins (int, str, …), whose dunder
  # operands are position-only, structurally satisfy the protocol.
  def __lt__(self, other: Any, /) -> bool: ...
  def __gt__(self, other: Any, /) -> bool: ...
  def __le__(self, other: Any, /) -> bool: ...
  def __ge__(self, other: Any, /) -> bool: ...

Variants on the same machine

Both algorithms adapt to a family of related problems with small edits.

Longest non-decreasing subsequence. To allow equal adjacent values (), change lower_bound to upper_bound: search for the first tail strictly greater than . An equal element then extends rather than overwrites, giving the non-strict relaxation. (In the DP, change the test to .)

Counting the number of LIS. Alongside track = the number of longest increasing subsequences ending at . When a strictly better predecessor is found (), reset ; when a tying predecessor is found (), accumulate . The answer sums over all achieving the global maximum . Speeding this counting variant to uses a Fenwick or segment tree keyed by value to roll up best-length-and-count over smaller predecessors.2

Russian doll envelopes. Each envelope has width and height , and one nests in another only if both dimensions are strictly larger; find the longest nesting chain. Reduce to LIS in two dimensions: sort by width ascending, and break ties by height descending, then run LIS on the height sequence alone. The descending tiebreak is what makes this valid: among envelopes of equal width, the descending order makes it impossible for two of them to both appear in an increasing height run (their heights decrease), so we never illegally nest two equal-width envelopes. With distinct widths this reduces a 2-D nesting to a plain 1-D LIS, solvable in .3

Bitonic and longest decreasing. A longest decreasing subsequence is just LIS on the reversed comparison (or on the negated array). A longest bitonic subsequence, one that increases then decreases, is computed by running the ending-here LIS left-to-right to get and a symmetric decreasing pass right-to-left to get ; the best bitonic peak at has length .

Bitonic length at each peak. A forward pass gives (longest increasing ending at ), a backward pass gives (longest decreasing starting at ); the peak at scores .

On the peak sits at the value : three elements climb up to it () and three descend from it (), and the avoids double-counting the peak, so the whole array is one bitonic run of length .

count_lis.pypython
from typing import Sequence, TypeVar

from comparable import Comparable

Value = TypeVar("Value", bound=Comparable)

def count_lis(values: Sequence[Value]) -> int:
  """
    The number of distinct longest strictly-increasing subsequences of\n
    `values`. An empty sequence has a single (empty) LIS.\n
  """
  count_total: int = len(values)
  if count_total == 0:
    return 1

  # length[i] = longest run ending at i; count[i] = how many such runs.
  length: list[int] = [1 for _ in range(count_total)]
  count: list[int] = [1 for _ in range(count_total)]

  for index in range(count_total):
    for earlier in range(index):
      if values[earlier] >= values[index]:
        continue

      # a strictly longer run through `earlier`: adopt its count.
      if length[earlier] + 1 > length[index]:
        length[index] = length[earlier] + 1
        count[index] = count[earlier]

      # another run of the same best length: add its ways in.
      elif length[earlier] + 1 == length[index]:
        count[index] += count[earlier]

  best_length: int = max(length)
  return sum(
    count[index]
    for index in range(count_total)
    if length[index] == best_length
  )
russian_doll_envelopes.pypython
from bisect import bisect_left
from typing import NamedTuple, Sequence

class Envelope(NamedTuple):
  """
    One envelope's outer dimensions: its width and its height.\n
  """
  width: int
  height: int

def max_envelopes(envelopes: Sequence[Envelope]) -> int:
  """
    Length of the longest chain of envelopes that nest strictly inside one\n
    another (both dimensions strictly increasing).\n
  """
  if not envelopes:
    return 0

  # width ascending; on equal width, height descending (the nesting trick).
  ordered: list[Envelope] = sorted(
    envelopes, key=lambda envelope: (envelope.width, -envelope.height)
  )

  # strict LIS over the height column via the tails array.
  tails: list[int] = []
  for envelope in ordered:

    # append a new pile or overwrite the first tail >= this height.
    position: int = bisect_left(tails, envelope.height)
    if position == len(tails):
      tails.append(envelope.height)
    else:
      tails[position] = envelope.height

  return len(tails)
bitonic_subsequence.pypython
from typing import Sequence, TypeVar

from comparable import Comparable

Value = TypeVar("Value", bound=Comparable)

def _ending_increasing_lengths(values: Sequence[Value]) -> list[int]:
  """
    `result[i]` = length of the longest strictly-increasing run ending at i.\n
  """
  count: int = len(values)
  lengths: list[int] = [1 for _ in range(count)]

  # extend each cell from the best smaller earlier element.
  for index in range(count):
    for earlier in range(index):
      if values[earlier] < values[index]:
        lengths[index] = max(lengths[index], lengths[earlier] + 1)

  return lengths

def longest_decreasing_subsequence_length(values: Sequence[Value]) -> int:
  """
    Length of a longest strictly-decreasing subsequence: the increasing run\n
    on the reversed sequence.\n
  """
  reversed_values: list[Value] = list(reversed(values))
  lengths: list[int] = _ending_increasing_lengths(reversed_values)
  return max(lengths, default=0)

def longest_bitonic_subsequence_length(values: Sequence[Value]) -> int:
  """
    Length of a longest bitonic subsequence — one that strictly increases up\n
    to a single peak, then strictly decreases. A lone element (and the empty\n
    sequence's 0) count as degenerate bitonic runs.\n
  """
  count: int = len(values)
  if count == 0:
    return 0

  # increasing[i] ends at i; decreasing[i] is the increasing run on the
  # reversed suffix, re-indexed forward, so it starts at i.
  increasing: list[int] = _ending_increasing_lengths(values)
  reversed_lengths: list[int] = _ending_increasing_lengths(
    list(reversed(values))
  )
  decreasing: list[int] = list(reversed(reversed_lengths))

  return max(
    increasing[index] + decreasing[index] - 1 for index in range(count)
  )
comparable.pypython
from typing import Any, Protocol, TypeVar


class Comparable(Protocol):
  """
    Anything orderable with `<` (int, float, str, tuple, date, …).\n
  """

  # `other` is position-only so built-ins (int, str, …), whose dunder
  # operands are position-only, structurally satisfy the protocol.
  def __lt__(self, other: Any, /) -> bool: ...
  def __gt__(self, other: Any, /) -> bool: ...
  def __le__(self, other: Any, /) -> bool: ...
  def __ge__(self, other: Any, /) -> bool: ...

Lower bounds, permutations, and a longer view

The patience-sorting bound is essentially optimal. Fredman (1975) proved that any comparison-based LIS algorithm needs comparisons in the worst case, so the tails-array method is asymptotically the best achievable under comparisons — the binary search is forced. Only by dropping to the integer-RAM model (values in a bounded range, van Emde Boas or -fast tries in place of binary search) can one shave the bound to ; the bounded-gap variant Longest Increasing Subsequence II requires a segment tree over the value axis instead of a plain tails array.

Patience sorting also appears in probability. Hammersley asked in 1972 how long the LIS of a random permutation of tends to be; the answer, , was determined by Logan and Shepp and by Vershik and Kerov (1977), and the full limiting distribution of the fluctuations — the Tracy–Widom distribution from random-matrix theory — was found by Baik, Deift, and Johansson (1999). That an interview-style array problem shares its scaling law with the largest eigenvalue of a random Hermitian matrix is one of the more surprising connections in combinatorics, and patience sorting is the constructive object underneath it (Aldous and Diaconis's 1999 survey Longest increasing subsequences: from patience sorting to the Baik–Deift–Johansson theorem is the readable entry point).4

The chain/antichain duality noted above (Mirsky and Dilworth) is the reason LIS generalizes cleanly to partial orders: Russian-doll envelopes, box-stacking (Maximum Height by Stacking Cuboids), and job-nesting are all longest chain in a poset problems, and the sort-then-LIS reduction works precisely when the poset can be linearized on one coordinate so that the residual constraint is one-dimensional. When it cannot — three or more strictly-independent dimensions — the problem becomes a longest chain in higher-dimensional dominance order, solvable with a -dimensional Fenwick tree in , the direct descendant of the Fenwick-tree counting that speeds up the LIS-counting variant.

Takeaways

  • The LIS is a longest strictly-increasing subsequence (order preserved, contiguity not required), not a substring.
  • The DP indexes by the ending index: , the answer is , and parent pointers reconstruct the subsequence.
  • The method keeps a sorted tails array where is the smallest tail of a length- run; each element triggers a lower_bound overwrite-or-append. Overwriting with a smaller tail never loses an achievable length, so is the LIS length; this is patience sorting.
  • Variants reuse the same algorithms: upper_bound for non-decreasing, parallel counts for the number of LIS, sort-then-LIS with a descending tiebreak for Russian-doll envelopes, and forward+backward passes for bitonic.
  • LIS equals the minimum antichain (decreasing-subsequence) cover, the pile count, a constructive instance of Mirsky's theorem.

Footnotes

  1. Skiena, § — Longest Increasing Subsequence: LIS as a canonical sequence DP, with the improvement over the naive quadratic fill.
  2. Erickson, Ch. — Dynamic Programming: augmenting an optimization DP with a parallel count array to enumerate optimal solutions.
  3. CLRS, Ch. 15 — Dynamic Programming (Problem 15-4): the longest-increasing-subsequence problem and its solution underpinning multi-dimensional nesting variants.
  4. Fredman (1975) for the comparison lower bound; Aldous & Diaconis (1999, Bull. AMS), Longest increasing subsequences: from patience sorting to the Baik–Deift–Johansson theorem, surveying the expected LIS of a random permutation and its Tracy–Widom fluctuations (Baik, Deift, Johansson, 1999).
Practice

╌╌ END ╌╌