Longest Increasing Subsequence
You are given an integer array nums. A subsequence is obtained by deleting zero or more elements without changing the order of the ones that remain. Return the length of the longest subsequence whose elements are in strictly increasing order. Equal neighbouring values do not count as increasing. Return the length only, not the subsequence itself.
- 1 ≤ nums.length ≤ 2500
- -10⁴ ≤ nums[i] ≤ 10⁴
- The subsequence must be strictly increasing
- Elements need not be adjacent, but must keep their original order
nums = [10, 9, 2, 5, 3, 7, 101, 18]4nums = [0, 1, 0, 3, 2, 3]4nums = [7, 7, 7, 7, 7]1nums = [5, 4, 3, 2, 1]1nums = [1, 2, 3, 4]4A subsequence keeps the original order but allows gaps. In [10, 9, 2, 5, 3, 7, 101, 18], the elements 2, 5, 7, 101 appear in that order — not next to each other, but never out of order — so [2, 5, 7, 101] is an increasing subsequence of length 4.
Strictly increasing means each element must be larger than the one before, so [7, 7, 7] has a longest increasing subsequence of length 1, not 3. That word is worth confirming out loud with an interviewer, because the non-strict version needs a different comparison in exactly one place and is otherwise identical.
Brute force is to generate all 2ⁿ subsequences and check each. For 2500 elements that number has 750 digits, so we need real structure.
The first instinct is to define f(i) as "the length of the longest increasing subsequence within the first i elements". It sounds right and it is useless. Suppose f(5) = 3 — you know a length-3 subsequence exists somewhere in that prefix, but you have no idea what its last value is, and that is precisely what you need in order to decide whether nums[5] can be appended to it.
Two prefixes with the same best length can end on very different values, and those futures differ. So that state is no good — a state must decide what can happen next, and this one does not.
f(i) as the length of the longest increasing subsequence that ends exactly at index i, using nums[i] as its final element. Now the ending value is known — it is nums[i] — and everything you need to extend is available.The price of this definition is that f(i) is no longer the answer for the prefix; it is a local fact. The overall answer becomes the maximum of f(i) over all i, because every increasing subsequence ends somewhere, and whichever index that is will have recorded it.
What can come before nums[i] in a subsequence ending at i? Any earlier element that is strictly smaller. If we choose j < i with nums[j] < nums[i], then the best we can do is the best subsequence ending at j, plus this one element:
f(i) = 1 + max( f(j) for all j < i with nums[j] < nums[i] )
and if no such j exists, f(i) = 1 — the element standing alone.
def length_of_lis(nums):
n = len(nums)
dp = [1] * n # every element is a subsequence of length 1
for i in range(n):
for j in range(i):
if nums[j] < nums[i]:
dp[i] = max(dp[i], dp[j] + 1)
return max(dp)Count the cost: index i scans i earlier elements, so the total is about N²/2 — around three million operations at the maximum size, which is comfortably fast enough. This version is worth being able to write instantly, because it generalises to almost every variant.
Note the loops read only smaller indices, so the natural left-to-right order is already correct, and the base case dp[i] = 1 is folded into the initialisation.
dp = [1, 1, 1, 2, 2, 3, 4, 4], and the answer is max(dp) = 4.
Look at where the maximum lives: at indices 6 and 7, not at the end. This is exactly why the answer is a maximum over the table rather than its last entry.
There is an O(N log N) solution, and it is worth learning properly rather than memorising, because the reasoning is beautiful and the code is four lines.
Keep an array tails, where tails[k] holds the smallest possible value that any increasing subsequence of length k+1 could end with, considering everything processed so far.
Why do we want the smallest ending? Because a smaller ending is strictly more useful — anything that can be appended to a subsequence ending in 9 can also be appended to one ending in 3, but not the other way round. Among all subsequences of a given length, the one with the smallest tail dominates all the others, so it is the only one worth remembering.
For each new value x:
x is larger than every tail, it extends the longest run we have: append it, and the answer grows by one.x and overwrite it with x. This says: there is now a subsequence of that length ending in something smaller, which is a strict improvement for the future and costs nothing in the present.from bisect import bisect_left
def length_of_lis(nums):
tails = []
for x in nums:
pos = bisect_left(tails, x) # leftmost index with tails[pos] >= x
if pos == len(tails):
tails.append(x) # x extends the longest run
else:
tails[pos] = x # x improves an existing length
return len(tails)Two facts make this work, and both deserve a moment.
tails is always sorted. A subsequence of length 3 contains, inside it, a subsequence of length 2 ending at a strictly smaller value, so the best length-2 tail can only be smaller than the best length-3 tail. Sortedness is what lets us binary-search, which is where the log N comes from.
Overwriting never breaks anything. Replacing tails[pos] does not destroy a subsequence we might still need, because the length recorded at that position is unchanged and its ending only got smaller — strictly more permissive going forward.
tails is not the increasing subsequence. Its length is correct, but its contents can be a mixture of values from different subsequences that never coexisted. On [10, 9, 2, 5, 3, 7, 101, 18] the array ends as [2, 3, 7, 18], which happens to be valid here; on other inputs it is not. If the actual subsequence is required, keep parent pointers alongside, or use the O(N²) table where reconstruction is straightforward. Claiming tails as the answer sequence is a classic interview stumble.For strictly increasing we use bisect_left; for the non-decreasing variant, where equal values are allowed, use bisect_right. That single character is the whole difference.
The recognition signal is broader than "increasing numbers". Reach for this whenever a problem asks for the longest chain of items where each one must beat the previous one on some ordering, and items may be skipped.
Concretely: stacking boxes that must fit inside one another, chains of pairs, scheduling non-overlapping intervals — many of these reduce to this shape after a sort, or to its mirror image. A two-dimensional version, where each item must beat the previous one on two keys at once, becomes exactly this once you sort on one key and run the chain on the other.
Two portable lessons to keep:
When a state does not determine the future, add whatever is missing — often "what does it end with?" — and accept that the answer becomes an aggregate over the table rather than one cell.
When you keep a set of candidates, ask whether some of them dominate others. Here, among all subsequences of a given length, only the one with the smallest tail could ever matter. Throwing away dominated candidates is what collapsed a quadratic scan into a binary search, and the same move appears all over algorithm design.