Master Longest Increasing Subsequence (LIS) completely — the O(n²) DP solution, the O(n log n) patience sorting algorithm, reconstructing the actual LIS, variants including longest non-decreasing subsequence and bitonic subsequence, and applications in scheduling and sequence analysis.
Definition
The Longest Increasing Subsequence (LIS) of an array is the longest subsequence where every element is strictly greater than the previous element.
Array: [10, 9, 2, 5, 3, 7, 101, 18]
Some increasing subsequences:
[2, 3, 7, 101] length 4
[2, 5, 7, 101] length 4
[2, 3, 7, 18] length 4
[10, 101] length 2
[9, 101] length 2
LIS length: 4
Solution 2 — O(n log n) with Patience Sorting
The key insight: maintain a tails array where tails[i] = smallest tail element of all increasing subsequences of length i+1.
import bisect
def lis_nlogn(nums):
"""
O(n log n) using binary search.
tails[i] = smallest tail of all IS of length i+1.
For each num: binary search for its position in tails.
"""
tails = []
for num in nums:
# Find leftmost position where tails[pos] >= num
pos = bisect.bisect_left(tails, num)
if pos == len(tails):
tails.append(num) # num extends the longest IS so far
else:
tails[pos] = num # Replace with smaller tail (greedy)
return len(tails)
print(lis_nlogn([10, 9, 2, 5, 3, 7, 101, 18])) # 4
print(lis_nlogn([4, 10, 4, 3, 8, 9])) # 3
Step-by-step trace for [3, 1, 4, 1, 5, 9, 2, 6]:
| Start | tails = [] |
| num=3: tails=[] | append → tails=[3] |
| num=1: bisect_left([3],1)=0 | replace → tails=[1] |
| num=4: bisect_left([1],4)=1 | append → tails=[1,4] |
| num=1: bisect_left([1,4],1)=0 | replace → tails=[1,4] |
| num=5: bisect_left([1,4],5)=2 | append → tails=[1,4,5] |
| num=9: bisect_left([1,4,5],9)=3 | append → tails=[1,4,5,9] |
| num=2: bisect_left([1,4,5,9],2)=1 | replace → tails=[1,2,5,9] |
| num=6: bisect_left([1,2,5,9],6)=3 | replace → tails=[1,2,5,6] |
| len(tails) = 4 | LIS length = 4 ✓ |
| Note | tails is NOT the actual LIS. It's a clever tool for counting. |
Why does this work?
tails is always sorted (invariant)tails[i] is the optimal (smallest) ending for IS of length i+1- Binary search finds where the current number fits or improves
- Replacing a larger tail with a smaller one maintains the invariant and allows future elements to extend longer sequences
Reconstruct the Actual LIS
import bisect
def lis_reconstruct(nums):
"""
Find the actual LIS, not just its length.
Uses parent tracking alongside the patience sort.
"""
if not nums: return []
n = len(nums)
tails = []
tails_indices = [] # Index in nums of each tail
parent = [-1] * n
for i, num in enumerate(nums):
pos = bisect.bisect_left(tails, num)
if pos == len(tails):
tails.append(num)
tails_indices.append(i)
else:
tails[pos] = num
tails_indices[pos] = i
# Set parent: this element extends the IS ending at tails[pos-1]
parent[i] = tails_indices[pos-1] if pos > 0 else -1
# Reconstruct by following parent pointers from the last tail
result = []
idx = tails_indices[-1]
while idx != -1:
result.append(nums[idx])
idx = parent[idx]
return list(reversed(result))
print(lis_reconstruct([10, 9, 2, 5, 3, 7, 101, 18]))
# [2, 3, 7, 101] or similar valid LIS
LIS Variants
Non-Decreasing (≥ instead of >)
def longest_non_decreasing(nums):
"""LIS where equal elements are allowed."""
tails = []
for num in nums:
pos = bisect.bisect_right(tails, num) # Use bisect_right!
if pos == len(tails): tails.append(num)
else: tails[pos] = num
return len(tails)
print(longest_non_decreasing([1, 3, 3, 5])) # 4 (the whole array)
print(lis_nlogn([1, 3, 3, 5])) # 3 (skip one 3)
Longest Decreasing Subsequence
def longest_decreasing(nums):
"""Reverse the array and find LIS."""
return lis_nlogn(nums[::-1])
print(longest_decreasing([5, 3, 4, 2, 1])) # 4 ([5,3,2,1])
Longest Bitonic Subsequence
A bitonic subsequence first increases then decreases.
def longest_bitonic(nums):
"""
For each index i, find:
- LIS ending at i (lis[i])
- LDS starting at i (lds[i]) = LIS of reversed array ending at i
Bitonic through i has length lis[i] + lds[i] - 1
"""
n = len(nums)
# LIS ending at each index
lis = [1] * n
for i in range(1, n):
for j in range(i):
if nums[j] < nums[i]:
lis[i] = max(lis[i], lis[j]+1)
# LDS starting at each index (LIS from the right)
lds = [1] * n
for i in range(n-2, -1, -1):
for j in range(i+1, n):
if nums[j] < nums[i]:
lds[i] = max(lds[i], lds[j]+1)
return max(lis[i] + lds[i] - 1 for i in range(n))
print(longest_bitonic([1, 11, 2, 10, 4, 5, 2, 1])) # 6 ([1,2,10,4,2,1])
Number of LIS
def count_lis(nums):
"""Count how many distinct LIS exist."""
n = len(nums)
length = [1] * n
count = [1] * n
for i in range(1, n):
for j in range(i):
if nums[j] < nums[i]:
if length[j]+1 > length[i]:
length[i] = length[j]+1
count[i] = count[j]
elif length[j]+1 == length[i]:
count[i] += count[j]
max_len = max(length)
return sum(c for l, c in zip(length, count) if l == max_len)
print(count_lis([1, 3, 5, 4, 7])) # 2 ([1,3,5,7] and [1,3,4,7])
print(count_lis([2, 2, 2, 2, 2])) # 5 (five length-1 LIS)
Classic Interview Problem — Russian Dolls / Box Stacking
LIS on pairs (sort by one dimension, find LIS on the other):
def max_envelopes(envelopes):
"""
Each envelope [w,h]. Find max envelopes you can nest (like Russian dolls).
Sort by width ascending, then height DESCENDING.
Then find LIS on heights only.
(Descending height ensures we don't count same-width envelopes in LIS)
"""
envelopes.sort(key=lambda x: (x[0], -x[1]))
heights = [h for _, h in envelopes]
return lis_nlogn(heights)
print(max_envelopes([[5,4],[6,4],[6,7],[2,3]])) # 3 ([2,3],[5,4],[6,7])
Complexity Summary
| Approach | Time | Space | Notes |
|---|
| O(n²) DP | O(n²) | O(n) | Simple, good for n ≤ 1000 |
| O(n log n) patience | O(n log n) | O(n) | For n ≤ 10^5 or more |
| With reconstruction | O(n log n) | O(n) | Needs parent array |
| Bitonic | O(n²) | O(n) | Run LIS/LDS for each index |
Summary
LIS is one of the most important DP problems:
- O(n²) solution:
dp[i] = max(dp[j]+1 for j<i where nums[j]<nums[i]) - O(n log n) solution: Maintain sorted tails array, binary search for position
- Key insight for O(n log n): Replacing a larger tail with a smaller one is always beneficial — it gives future elements more chances to extend the subsequence
Common interview appearance: "Scheduling" problems that reduce to LIS, Russian dolls, envelope nesting, and any problem requiring "select a chain where each element satisfies a condition relative to the previous one."
*Next Lesson: Matrix Chain Multiplication*