Longest Increasing Subsequence
Patience sort with binary search
O(n²) DP first, then learn the O(n log n) patience sorting approach.
Problem
Given an integer array nums, return the length of the longest strictly increasing subsequence. A subsequence keeps order but may drop elements; it does not have to be contiguous.
Intuition
What does dp[i] depend on? The longest increasing subsequence ending at i is one more than the longest ending at some earlier j where nums[j] < nums[i]:
dp[i] = 1 + max(dp[j] for j < i if nums[j] < nums[i])
That's quadratic. We scan every earlier position for each i.
The n log n trick is a reframe: we don't actually need dp per index — we only need the smallest possible tail of an increasing subsequence of each length. Keep a sorted array tails where tails[k] = the smallest value that any length-(k + 1) increasing subsequence can end in. For each new x:
- If
xis larger than every tail, it extends the longest sequence by one — push it. - Otherwise, it replaces the first tail
>= x. The subsequence of that length now ends on a smaller value, which is strictly better for extending later.
Final length of tails is the LIS length. The replacement uses binary search, giving O(n log n).
tails is not an LIS itself — the actual subsequence may interleave values that got overwritten. You only get the length for free. Reconstructing the LIS needs back-pointers (see #673 Number of LIS).
Approach
Quadratic DP
O(n²)O(n)The direct translation of the recurrence. Easy to derive, easy to debug, plenty fast for n ≤ 2500. If the interview constraint allows it, this is often the right answer — clarity beats cleverness.
impl Solution {
pub fn length_of_lis(nums: Vec<i32>) -> i32 {
let n = nums.len();
if n == 0 {
return 0;
}
let mut dp = vec![1i32; n];
let mut best = 1;
for i in 1..n {
for j in 0..i {
if nums[j] < nums[i] {
dp[i]= dp[i].max(dp[j] + 1);
}
}
best= best.max(dp[i]);
}
best
}
}function lengthOfLIS(nums: number[]): number {
const n = nums.length;
if (n === 0) return 0;
const dp = new Array<number>(n).fill(1);
let best = 1;
for (let i = 1; i < n; i++) {
for (let j= 0; j < i; j++) {
if (nums[j] < nums[i]) {
dp[i]= Math.max(dp[i], dp[j] + 1);
}
}
best= Math.max(best, dp[i]);
}
return best;
}Patience Sort + Binary Search
O(n log n)O(n)Maintain tails, a sorted array of smallest tails per length. For each x, binary-search the first tails[k] >= x (strict increase → >=, not >). If none exists, append; otherwise overwrite.
The binary search is the entire speedup. Rust's Vec::partition_point returns the index where a predicate flips from true to false — exactly what we need for "first tail >= x". In TypeScript we write the binary search by hand.
impl Solution {
pub fn length_of_lis(nums: Vec<i32>) -> i32 {
let mut tails: Vec<i32> = Vec::with_capacity(nums.len());
for x in nums {
// First index where tails[k] >= x.
let k = tails.partition_point(|&t| t < x);
if k= tails.len() {
tails.push(x);
} else {
tails[k]= x;
}
}
tails.len() as i32
}
}function lengthOfLIS(nums: number[]): number {
const tails: number[] = [];
for (const x of nums) {
// First index where tails[k] >= x.
let lo = 0;
let hi = tails.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (tails[mid] < x) lo = mid + 1;
else hi = mid;
}
if (lo === tails.length) tails.push(x);
else tails[lo] = x;
}
return tails.length;
}Trace
Patience sort on nums = [10, 9, 2, 5, 3, 7, 101, 18]:
| i | x | action | tails |
|---|---|---|---|
| 0 | 10 | push | [10] |
| 1 | 9 | replace 10 | [9] |
| 2 | 2 | replace 9 | [2] |
| 3 | 5 | push | [2, 5] |
| 4 | 3 | replace 5 | [2, 3] |
| 5 | 7 | push | [2, 3, 7] |
| 6 | 101 | push | [2, 3, 7, 101] |
| 7 | 18 | replace 101 | [2, 3, 7, 18] |
Final tails.len() = 4. ✓ Note the final tails content looks like a plausible LIS, but that's coincidence — if the replacements crossed each other earlier, the stored values would no longer form a real subsequence of the input.
Edge cases
nums = []— return0. Thepartition_pointapproach handles this naturally (emptytails); the DP form needs the explicit check.- Strictly increasing input — every element pushes;
tails.len()equalsnums.len(). - All equal — return
1. Because the comparison is<(strict), everyxafter the first replacestails[0]without extending. - Duplicates in a generally-increasing sequence — replace semantics keep the smallest tail at each length, so
[1, 2, 2, 3]returns3, not4.
Swap < for <= in the binary-search predicate to solve the non-strict variant (longest non-decreasing subsequence). Same algorithm; one character of difference.
Takeaway
When a DP recurrence looks like "max over all earlier states matching a predicate," check if the state space has extra structure you can exploit. Here the structure is: for each length, only the smallest tail matters. That's what lets us replace a per-index DP with a single sorted array.
This patience-sort reframe is one of the oldest tricks in competitive programming and shows up whenever "maintain best-so-far per category" is enough to answer the outer question. The same shape reappears in #673 (count LIS with back-pointers), #354 Russian Doll Envelopes (2D LIS after sort), and in the offline-query optimizations that underpin monotonic structures like #1143 LCS.
More in 1D DP
Fibonacci in disguise. dp[i] = dp[i-1] + dp[i-2]. Start here.
dp[i] = max(dp[i-1], dp[i-2] + nums[i]). The ‘take or skip’ template.
dp[i] = can we segment s[0..i]? Try all possible last words.
Track both max AND min at each position (negatives flip signs).