Dynamic Programming
Advanced 11 min read

Longest increasing subsequence

In one line

The longest increasing subsequence can be found in O(n^2) with dp[i] = longest run ending at i, or in O(n log n) by keeping the smallest possible tail for every length and binary searching it.

What it is

Given an array, the longest increasing subsequence (LIS) is the longest set of elements, kept in their original order but not necessarily adjacent, where each is strictly larger than the previous. For [10, 9, 2, 5, 3, 7, 101, 18] one LIS is [2, 3, 7, 18], length 4.

The simple DP defines dp[i] = length of the longest increasing subsequence that ends exactly at index i. dp[i] = 1 + max(dp[j]) over j < i with nums[j] < nums[i], and the answer is the maximum dp[i]. That is O(n^2).

The faster method (patience sorting) keeps an array tails where tails[k] is the smallest possible last value of an increasing subsequence of length k + 1. tails is always sorted, so each new number finds its place with binary search in O(log n), giving O(n log n) overall.

Why interviewers ask

LIS is a classic follow-up question: give the O(n^2) DP, then improve it to O(n log n). Interviewers check that you understand why tails stays sorted, that the tails array is not itself the subsequence, and that you can adapt it to variants such as Russian doll envelopes or non-decreasing sequences.

How it works

  1. 1O(n^2): set dp[i] = 1 for all i. For each i, look at every j < i; if nums[j] < nums[i], dp[i] = max(dp[i], dp[j] + 1). Return max(dp).
  2. 2O(n log n): start with an empty tails array. For each x, find k = bisect_left(tails, x), the first position with tails[k] >= x.
  3. 3If k equals len(tails), x is larger than every tail, so it extends the longest subsequence: append it.
  4. 4Otherwise replace tails[k] with x. A subsequence of length k + 1 can now end in a smaller value, which leaves more room for future numbers.
  5. 5The answer is len(tails). To recover an actual subsequence, also store, for each element, the index of the element before it at the time it was placed, and walk back from the last tail.
  6. 6For non-decreasing (equal values allowed) use bisect_right instead of bisect_left.

Worked example · tails on [10, 9, 2, 5, 3, 7, 101, 18]

  1. 10: tails empty, append. tails = [10].
  2. 9: first tail >= 9 is 10 at k = 0, replace. tails = [9]. 2: replace again. tails = [2].
  3. 5: larger than all tails, append. tails = [2, 5]. 3: first tail >= 3 is 5, replace. tails = [2, 3].
  4. 7: append. tails = [2, 3, 7]. 101: append. tails = [2, 3, 7, 101].
  5. 18: first tail >= 18 is 101 at k = 3, replace. tails = [2, 3, 7, 18]. Length 4.
  6. The O(n^2) dp for the same array is [1, 1, 1, 2, 2, 3, 4, 4], and max(dp) = 4 agrees.
  7. Caution: tails is not always a real subsequence. For [3, 4, 1], tails ends as [1, 4], but 1 comes after 4 in the array; the length 2 is still correct.

Picture it

Patience sorting is like dealing cards into piles where each card goes on the leftmost pile whose top card is at least as large, or starts a new pile on the right. The number of piles at the end equals the length of the longest increasing subsequence, and the pile tops are exactly the tails array.

Reference code

from bisect import bisect_left


def lis_quadratic(nums):
    """dp[i] = length of the LIS ending exactly at i. O(n^2)."""
    if not nums:
        return 0
    dp = [1] * len(nums)
    for i in range(len(nums)):
        for j in range(i):
            if nums[j] < nums[i]:
                dp[i] = max(dp[i], dp[j] + 1)
    return max(dp)


def lis(nums):
    """Patience sorting. O(n log n)."""
    tails = []        # tails[k] = smallest tail of an increasing run of length k+1
    for x in nums:
        k = bisect_left(tails, x)      # first tail >= x
        if k == len(tails):
            tails.append(x)            # extends the longest run
        else:
            tails[k] = x               # better (smaller) tail for length k+1
    return len(tails)

# lis([10, 9, 2, 5, 3, 7, 101, 18]) -> 4

lis_quadratic is the direct DP and also supports variants like counting. lis keeps the smallest tail per length; because tails is sorted, bisect_left finds the spot in O(log n), so the whole run is O(n log n) time and O(n) space.

Key patterns

Classic LIS

When: Longest strictly increasing subsequence

How: tails with bisect_left; O(n log n).

Non-decreasing variant

When: Equal values may repeat

How: Use bisect_right so equal values extend instead of replace.

Russian doll envelopes (2D LIS)

When: Nest pairs (w, h) strictly in both dimensions

How: Sort by width ascending and height descending for equal widths, then LIS on heights. The descending tie-break stops equal widths from nesting.

Number of LIS

When: Count how many longest subsequences exist

How: O(n^2) DP with two arrays: length[i] and count[i].

Longest chain / divisible subset

When: Pairs or numbers must link under a rule

How: Sort first, then LIS-style DP with the custom 'can follow' condition (or greedy by end for pair chains).

Real-world use

Patience diff

The patience diff algorithm (in Bazaar and available as git diff --patience) matches lines that appear exactly once in both files and keeps the longest increasing subsequence of their positions as stable anchors, producing more readable diffs for moved code.

Stacking and nesting problems

Packaging and logistics tools that nest boxes or stack items by two dimensions reduce the problem to LIS after sorting, which is the Russian doll envelopes problem.

Trend analysis

Analytics on time series (prices, user metrics) sometimes measure the longest strictly rising run, allowing dips in between, as a robustness indicator; that is an LIS over values in time order.

Sorting measures

n minus the LIS length is the minimum number of elements you must move to sort a sequence, which is used to measure how 'unsorted' data is, for example when deciding how to reorder a playlist or a ranked list with the fewest moves.

In a project

A drag-and-drop to-do app syncs a user's reordered list to the server. Sending the whole list on every change is wasteful, so you want to send the minimum set of 'move item' operations.

  1. Map each item in the new order to its index in the old order, giving a sequence of old indices.
  2. Compute the LIS of that sequence with the O(n log n) tails method plus parent pointers to recover the actual items.
  3. Items in the LIS keep their relative order and stay where they are; every other item is sent as a move operation.
  4. Apply the moves on the server and verify the resulting order matches the client in tests with random reorderings.

Impact: A typical reorder sends one or two move operations instead of hundreds of positions, reducing sync payloads and conflict risk; this is the same idea front-end frameworks such as Vue 3 and Inferno use in their list diffing to minimise DOM moves.

Trade-offs

Use it when

  • Longest ordered chain where you may skip elements
  • Problems that reduce to LIS after sorting (envelopes, boxes, stacking)
  • Minimum moves to sort or reorder (n minus LIS)

Avoid it when

  • The run must be contiguous: a single linear scan is enough
  • You need all LIS or their count: use the O(n^2) DP with counts
  • The ordering is partial in more than two dimensions: general longest path in a DAG is needed

Cost: O(n^2) time and O(n) space for the simple DP; O(n log n) time and O(n) space with patience sorting.

Common mistakes

  • Returning the tails array as the actual subsequence.
  • Using bisect_right for a strictly increasing LIS (it lets equal values extend).
  • Returning dp[n-1] instead of max(dp) in the O(n^2) version.
  • Confusing subsequence with contiguous subarray.
  • In Russian doll envelopes, sorting heights ascending for equal widths, which lets equal widths nest.

Interview questions

Quick revision

  • dp[i] = 1 + max(dp[j]) for j < i, nums[j] < nums[i]; answer max(dp).
  • tails[k] = smallest tail of an increasing run of length k + 1.
  • bisect_left for strict, bisect_right for non-decreasing.
  • Append if larger than all, else replace: answer = len(tails).
  • tails is not the subsequence; store parents to rebuild it.
  • Envelopes: sort (w asc, h desc), LIS on h.
  • O(n^2) simple, O(n log n) patience.

Check yourself

1. What is the LIS length of [0, 1, 0, 3, 2, 3]?

2. After processing [10, 9, 2, 5, 3, 7], what is tails?

3. Which binary search should you use for a strictly increasing LIS?

4. In Russian doll envelopes, why sort heights descending for equal widths?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free