~/dynamic-programming/lis

Longest increasing subsequence

The longest run of values that only go up, picked in order from an array. O(n²) with a simple DP, O(n log n) with binary search.

what

Keep tails[k], the smallest value that can end an increasing subsequence of length k + 1. Each new value appends to tails or replaces the first tail >= it.

use when

You want the longest chain that increases, the fewest deletions to make an array sorted, or nesting boxes and envelopes after a sort.

time

O(n log n), or O(n²) for the plain DP

space

O(n)

You’ll recognise it when

  • You pick elements in their original order (skipping any you like) and the picks must keep going up.
  • The question asks for the fewest deletions that leave an array sorted: that’s n minus the LIS.
  • Items nest or stack only if they’re bigger in two dimensions (envelopes, boxes, people on shoulders). Sort by one dimension and it becomes an LIS on the other.
  • n is up to 10⁵, so the O(n²) DP is too slow and you need the O(n log n) version.

It’s easy to confuse with the longest increasing subarray, where the elements must be next to each other; that one is a single linear scan.

The idea

Deal a shuffled deck of numbered cards into piles, left to right. Each card goes on the leftmost pile whose top card is at least as big; if there is none, it starts a new pile on the right. The number of piles at the end is the length of the longest increasing run you could have picked from the deck. A small top card is good news: it lets more future cards land on the pile after it.

Start with the plain DP. Let dp[i] be the length of the longest increasing subsequence that ends at a[i]. It is 1 (just a[i]) plus the best dp[j] over earlier j with a smaller value. The answer is the largest dp[i], not the last one:

def lis_quadratic(a):
dp = [1] * len(a) # dp[i]: longest increasing run ending at a[i]
for i in range(len(a)):
for j in range(i):
if a[j] < a[i]:
dp[i] = max(dp[i], dp[j] + 1)
return max(dp, default=0)

That’s O(n²): fine for a few thousand values, too slow for 10⁵. The fast version keeps less. For each length it remembers only the smallest value that can end a run of that length, in an array called tails. Among all runs of length 3, the one ending lowest is the easiest to extend, so it’s the only one worth keeping. tails is always sorted, which means binary search can find where each new value belongs.

How it works

Read the values left to right and update tails for each one. The graphic follows the steps as you scroll. Turn on quiz me to predict each slot yourself, or edit the array: try duplicates, or a decreasing run.

  1. Start with an empty tails. tails[k] will be the smallest value that can end an increasing subsequence of length k + 1.
  2. Take the next value x. Every step asks one question: which run can x finish best?
  3. If x is bigger than every tail, it extends the longest run: append it. The LIS so far just got one longer. The len row under the input records pos + 1 for each value: the longest run ending there, the same number as dp[i] in the slow DP.
  4. Otherwise x replaces a tail. Here 2 replaces 5: a run of length 1 ending in 2 is better than one ending in 5, because more values can follow it.
  5. To find the slot, binary search for the first tail >= x, the lower bound. For x = 6 in [2, 8], that’s pos = 1: 2 is smaller, 8 is not.
  6. tails[pos] = x. Nothing gets longer, but the length-2 run now ends at 6 instead of 8. Every tail only ever goes down, and tails stays sorted.
  7. A value equal to a tail lands on that tail and changes nothing. That’s what makes this the strictly increasing version: a second 3 can’t follow the first.
  8. Watch tails here: [1, 3, 6, 7]. The 1 appears after the 7 in the input, so this is not a real subsequence. Each slot is a different run’s best ending; only the length is guaranteed.
  9. When every value is placed, the LIS length is len(tails).
  10. To get an actual LIS, remember for each value the tail it extended (the value at pos - 1 when it was placed). Start at the last tail and follow those parent links back.
  11. The chain gives 2, 3, 6, 7, a real increasing subsequence of length 4. tails had the right length but the wrong values.
loading lis…

Why it’s correct: after each step, tails[k] is the smallest ending value of any increasing run of length k + 1 seen so far. A new x can extend every run whose end is below x; the longest such run has length pos, the number of tails smaller than x. So x ends a run of length pos + 1, and since tails[pos] >= x, x is the new smallest ending for that length. No other slot changes. tails only gets longer when a longer run exists, so its final length is the LIS.

def lis_length(a):
"""Length of the longest strictly increasing subsequence of a."""
tails = []
for x in a:
# Lower bound: the first slot whose tail is >= x.
lo, hi = 0, len(tails)
while lo < hi:
mid = (lo + hi) // 2
if tails[mid] < x:
lo = mid + 1
else:
hi = mid
pos = lo
if pos == len(tails):
tails.append(x)
else:
tails[pos] = x
return len(tails)
#include <vector>
using namespace std;
// Length of the longest strictly increasing subsequence of a.
int lisLength(const vector<int>& a) {
vector<int> tails;
for (int x : a) {
// Lower bound: the first slot whose tail is >= x.
int lo = 0, hi = tails.size();
while (lo < hi) {
int mid = lo + (hi - lo) / 2;
if (tails[mid] < x) lo = mid + 1;
else hi = mid;
}
int pos = lo;
if (pos == (int)tails.size()) {
tails.push_back(x);
} else {
tails[pos] = x;
}
}
return tails.size();
}
class Lis {
// Length of the longest strictly increasing subsequence of a.
static int lisLength(int[] a) {
int[] tails = new int[a.length];
int len = 0;
for (int x : a) {
// Lower bound: the first slot whose tail is >= x.
int lo = 0, hi = len;
while (lo < hi) {
int mid = lo + (hi - lo) / 2;
if (tails[mid] < x) lo = mid + 1;
else hi = mid;
}
int pos = lo;
if (pos == len) {
tails[len++] = x;
} else {
tails[pos] = x;
}
}
return len;
}
}

The code keeps values only, which is all the length needs. To rebuild a sequence, keep indices in tails plus a parent array, as the graphic does:

from bisect import bisect_left
def lis_sequence(a):
tails, tail_idx = [], [] # tail values, and where each came from
parent = [-1] * len(a)
for i, x in enumerate(a):
pos = bisect_left(tails, x)
if pos > 0:
parent[i] = tail_idx[pos - 1]
if pos == len(tails):
tails.append(x); tail_idx.append(i)
else:
tails[pos] = x; tail_idx[pos] = i
seq, i = [], tail_idx[-1] if a else -1
while i != -1:
seq.append(a[i]); i = parent[i]
return seq[::-1]

Why it’s O(n log n)

Each of the n values does one binary search over tails, which has at most n entries: O(log n) per value, O(n log n) total. tails and the parent links take O(n) space.

Version Time Space n = 10⁵
dp[i] over all j < i O(n²) O(n) 5 × 10⁹ steps, too slow
tails + binary search O(n log n) O(n) about 1.7 × 10⁶ steps

The O(n²) DP is still worth knowing: it adapts easily when the rule isn’t just “smaller value”, for example counting runs or adding weights.

Common mistakes

Returning dp[-1] in the O(n²) version

dp[i] is the best run ending at i, and the best run needn’t end at the last element. On [1, 2, 3, 0], dp[-1] is 1 but the answer is 3.

return dp[-1] # ✗ best run ending at the last value
return max(dp, default=0) # ✓ best run ending anywhere

Printing tails as the answer

tails has the right length, but it mixes endings from different runs. On [3, 1, 4, 1, 5, 9, 2, 6] it ends as [1, 2, 5, 6], yet the 2 comes after the 5 in the input. Keep parent links if you need the sequence.

Upper bound when you want strictly increasing

With bisect_right, an x equal to a tail goes after it, so equal values extend the run. That computes the longest non-decreasing subsequence. On [2, 2, 2] it returns 3 instead of 1.

pos = bisect_right(tails, x) # ✗ counts 2, 2, 2 as increasing
pos = bisect_left(tails, x) # ✓ equal values replace, never extend

Envelopes sorted with both keys ascending

In Russian doll envelopes, sorting by width then height, both ascending, lets two envelopes of the same width both join the height LIS, even though they can’t nest. Sort heights descending within the same width.

env.sort() # ✗ (1,1), (1,2), (1,3) → 3
env.sort(key=lambda e: (e[0], -e[1])) # ✓ heights 3, 2, 1 → 1

Variations

  • Reconstructing the sequence. Store indices in tails and a parent for every element, then walk back from the last tail, as in the snippet above. With the O(n²) DP, record which j gave the best dp[i].
  • Non-decreasing subsequence. Allow equal neighbours by using the upper bound (bisect_right, std::upper_bound) instead of the lower bound. CSES Towers is this in disguise: the fewest towers equals the longest non-decreasing run.
  • Russian doll envelopes. Sort by width ascending and, for equal widths, height descending. Then the answer is the strict LIS of the heights.
  • Number of LISs. Next to dp[i], keep count[i], the number of best runs ending at i: when dp[j] + 1 beats dp[i], copy count[j]; when it ties, add it. Sum the counts of every i with the maximum dp[i]. This is O(n²).
  • Longest decreasing, and deletions. For a decreasing run, negate the values or reverse the comparison. The fewest deletions to leave an array strictly increasing is n minus its LIS.

Check yourself

5 quick questions. Pick an answer to see why it's right or wrong.

  1. 1

    Envelope (w, h) fits inside another only if both its width and height are strictly smaller. With up to 10^5 envelopes, how do you find the longest chain of nested envelopes?

  2. 2

    What does this print?

    import bisect
    tails = []
    for x in [3, 1, 4, 1, 5, 9, 2, 6]:
    i = bisect.bisect_left(tails, x)
    if i == len(tails):
    tails.append(x)
    else:
    tails[i] = x
    print(tails, len(tails))
  3. 3

    This O(n²) LIS has a bug. What does it print?

    def lis(a):
    dp = [1] * len(a)
    for i in range(len(a)):
    for j in range(i):
    if a[j] < a[i]:
    dp[i] = max(dp[i], dp[j] + 1)
    return dp[-1]
    print(lis([1, 2, 3, 0]))
  4. 4

    What does this print?

    import bisect
    def lis_len(a, search):
    tails = []
    for x in a:
    i = search(tails, x)
    if i == len(tails):
    tails.append(x)
    else:
    tails[i] = x
    return len(tails)
    a = [2, 2, 2, 1, 2]
    print(lis_len(a, bisect.bisect_left), lis_len(a, bisect.bisect_right))
  5. 5

    The dp[i] = “longest run ending at i” DP and the tails method both find the LIS. What are their running times?

Practice problems

Further reading

esc