Longest Increasing Subsequence: Patience Sorting
Find the longest strictly increasing subsequence in O(n log n) time using patience sorting and binary search techniques.
Longest Increasing Subsequence: Patience Sorting
The longest increasing subsequence (LIS) problem asks: given a sequence of numbers, find the longest subsequence where each element is strictly greater than its predecessor. The subsequence doesn’t need to be contiguous—gaps are allowed as long as the ordering is preserved. A naive solution compares all pairs, giving O(n²) time; but a clever algorithm using patience sorting and binary search achieves O(n log n), transforming what’s often an interview showstopper into an elegant demonstration of optimizing beyond the obvious.
The key insight is that the minimum ending value for all increasing subsequences of length k can be tracked in a tails array. Processing each element, we binary search where it would fit in this array. Smaller ending values allow longer sequences to extend further, so we update only when we find a better (smaller) tail. The length of the tails array at the end is the LIS length.
Introduction
The longest increasing subsequence (LIS) problem asks a deceptively simple question: given a sequence of numbers, what is the longest subsequence where each element is strictly greater than its predecessor? The answer reveals an elegant algorithm that achieves O(n log n) time—dramatically better than the naive O(n²) comparison-based approach. This isn’t just a theoretical improvement; it’s the difference between solving problems with n up to 10⁵ versus being limited to n below a few thousand.
LIS appears in diverse applications: stacking boxes with dimensions, scheduling to maximize non-overlapping intervals, and even protein folding problems in computational biology. The key insight is that patience sorting—the same algorithm used to sort a deck of cards—naturally discovers the LIS length through the mechanics of binary search on a “tails” array. Each element placed into the right pile reveals how long an increasing sequence ending at that value can be.
This guide covers the O(n log n) patience sorting algorithm with full implementation details, explains why the tails array invariant holds, and shows how to reconstruct the actual subsequence when needed. You’ll understand the crucial difference between bisect_left and bisect_right for strict versus non-strict increasing sequences, and learn when the O(n²) DP approach is actually preferable despite its worse asymptotic complexity.
When to Use
LIS applies when:
- Box stacking problems — Stacking boxes where each has dimensions
- Maximum non-overlapping intervals — Finding largest subset of non-overlapping segments
- Hotel renovation planning — Maximum guests in hotel given check-in/check-out times
- Russian doll envelope problems — Nesting objects with multiple constraints
- Online judge problems with n up to 10⁵ — O(n²) too slow, need O(n log n)
When Not to Use
- When you need the actual subsequence, not just its length (the O(n log n) algorithm doesn’t directly reconstruct)
- Problems where elements are unique isn’t guaranteed (strict vs non-strict inequality matters)
- When O(n²) is fast enough for small inputs
Architecture: Patience Sorting + Binary Search
graph TD
A["Input: arr = [3,1,4,2,5]"] --> B[Initialize tails array]
B --> C[Process each element]
C --> D{Insert position?}
D -->|Binary search| E["Update tails[pos] = element"]
E --> F{"tails[pos] updated?"}
F -->|Yes| G[Length unchanged]
F -->|"No (append)"| H[Length increases]
G --> C
H --> C
C --> I["LIS length = len(tails)"]
Each element is placed at the position of the first tails element >= it. Smaller tails values leave more room for future elements to extend.
Implementation
O(n log n) LIS Using Binary Search
import bisect
def lis_length(nums):
"""
Find LIS length in O(n log n) time using patience sorting.
Returns the length of the longest increasing subsequence.
"""
if not nums:
return 0
tails = []
for num in nums:
# Find position where num should be inserted
pos = bisect.bisect_left(tails, num)
if pos == len(tails):
# num extends longest subsequence
tails.append(num)
else:
# num improves (smaller) tail for length pos+1
tails[pos] = num
return len(tails)
Reconstruct Actual LIS
def lis_reconstruct(nums):
"""
Find actual LIS in O(n log n) time.
Returns one valid LIS (not necessarily unique).
"""
if not nums:
return []
# Phase 1: Find LIS length and track positions
tails = []
tail_positions = [] # Which index in original nums ended each tail
for i, num in enumerate(nums):
pos = bisect.bisect_left(tails, num)
if pos == len(tails):
tails.append(num)
tail_positions.append(i)
else:
tails[pos] = num
tail_positions[pos] = i
# Phase 2: Reconstruct by backtracking
lis_length = len(tails)
lis = [0] * lis_length
prev_value = float('-inf')
pos = len(tails) - 1
# Find the actual LIS by tracing from the largest tail
for i in range(len(nums) - 1, -1, -1):
if nums[i] < prev_value:
continue
if pos >= 0 and i == tail_positions[pos]:
lis[pos] = nums[i]
prev_value = nums[i]
pos -= 1
if pos < 0:
break
return lis
O(n²) DP Approach (for reference)
def lis_length_dp(nums):
"""
O(n²) DP approach - easier to understand but slower.
dp[i] = LIS length ending at index i.
"""
if not nums:
return 0
n = len(nums)
dp = [1] * n
for i in range(1, n):
for j in range(i):
if nums[j] < nums[i]:
dp[i] = max(dp[i], dp[j] + 1)
return max(dp)
Production Failure Scenarios
| Failure | Cause | Mitigation |
|---|---|---|
| Wrong binary search variant | Using bisect_right instead of bisect_left | Use bisect_left for strictly increasing, bisect_right for non-decreasing |
| Off-by-one in reconstruction | Mismatched index tracking | Test carefully with duplicate values |
| Strict vs non-strict confusion | ”Increasing” vs “non-decreasing” | Clarify problem: strictly increasing (<) or non-decreasing (<=) |
| Negative numbers | Not handling negative input correctly | Algorithm works for any comparable elements |
Common Pitfalls / Anti-Patterns
- Confusing bisect_left vs bisect_right — For strict increasing, use
bisect_left; for non-decreasing (allow equal), usebisect_right - Not handling empty input — LIS of empty array is 0
- Reconstruction complexity — The O(n log n) algorithm finds length only; reconstruction requires additional tracking
- Multiple valid LIS — Algorithm returns one valid LIS, not necessarily the lexicographically smallest
Trade-off Analysis
| Approach | Time Complexity | Space Complexity | Finds Length | Reconstructs LIS | Counts LIS |
|---|---|---|---|---|---|
| O(n²) DP | O(n²) | O(n) | Yes | Yes | Yes |
| O(n log n) Patience Sorting | O(n log n) | O(n) | Yes | With tracking | No |
| O(n log n) Fenwick Tree | O(n log n) | O(n) | Yes | Yes | Yes |
Choosing the Right Approach
- Use O(n²) DP when n ≤ 1000 and you need the actual subsequence with minimum code complexity
- Use O(n log n) Patience Sorting when n is large (up to 10⁵) and you only need the length
- Use O(n log n) Fenwick Tree when you need counts, full reconstruction, or coordinate compression is natural
Quick Recap Checklist
- tails[pos] = smallest ending value of length (pos+1) subsequence
- bisect_left finds first >=, enabling strict increasing property
- len(tails) at end = LIS length
- For non-strict increasing, use bisect_right instead
- Test with: all decreasing, all same, already increasing, single element
Interview Questions
By definition, tails[i] stores the minimum possible tail value for an
increasing subsequence of length i+1. When processing a new element,
we find its position using binary search—the smallest tail >= num.
Placing num there maintains the invariant since num
is <= previous value but extends a longer or equally-long subsequence.
In patience sorting, you place cards into piles following rules: place a card on the leftmost pile whose top card is >= the new card. The number of piles equals the LIS length. The tails array is exactly these pile top cards. This connection proves the algorithm works—the piles provide an ordering that captures increasing subsequences.
Yes. Sort envelopes by width ascending, then height descending for equal widths. This prevents counting envelopes with same width as both fit. Then find LIS on heights using O(n log n). The sorting ensures width constraint is satisfied while height LIS finds the maximum nesting depth.
LIS finds the longest subsequence within a single array where elements strictly increase. LCS finds the longest subsequence common to two sequences, preserving order but not necessarily increasing values. LCS can be reduced to LIS on a transformed array when one sequence has unique elements, but they solve different fundamental problems.
You can find LDS by negating all elements and applying the LIS algorithm, or by reversing the comparison direction. Alternatively, compute LIS on the reversed array. For strictly decreasing, use bisect_left on negated values; for non-increasing, use bisect_right on negated values.
The DP approach defines dp[i] as the length of the longest increasing subsequence ending at index i. Initialize all dp[i] = 1. For each i, check all j < i: if nums[j] < nums[i], update dp[i] = max(dp[i], dp[j] + 1). The answer is max(dp).
- Time: O(n²)
- Space: O(n)
- Advantage: Simple to implement and easy to modify for LIS reconstruction
The space complexity is O(n) in the worst case (when the array is already increasing, the tails array grows to size n). For reconstruction with backtracking, an additional O(n) array stores parent indices, keeping total space O(n). The algorithm uses no recursion stack beyond iteration.
Use an O(n²) DP with two arrays: length[i] for LIS length ending at i, and count[i] for number of LIS ending at i. For each pair (j, i), if nums[j] < nums[i]:
- If
length[j] + 1 > length[i]: updatelength[i] = length[j] + 1,count[i] = count[j] - If
length[j] + 1 == length[i]: addcount[j]tocount[i]
Sum counts for all indices with maximum length. For O(n log n), use a Fenwick tree over coordinate-compressed values.
For strictly increasing LIS: duplicates are not allowed in the subsequence. Using bisect_left ensures we replace the first tail >= num, so equal elements replace each other and a duplicate will never extend the LIS. For non-decreasing LIS (allow equals): use bisect_right so equal elements are placed after existing tails, allowing them to extend the sequence.
Yes. Coordinate-compress the values, then process left to right. For each value, query the maximum LIS length among values less than it (range max query), add 1, and update the Fenwick or segment tree at the current value's position. This achieves O(n log n) and naturally extends to counting the number of LIS and 2D problems like Russian Doll Envelopes with multiple constraints.
Take nums = [3, 1, 4, 2, 5]:
- 3: tails=[], pos=0, len=0, append → tails=[3]
- 1: bisect_left([3],1)=0, replace → tails=[1]
- 4: bisect_left([1],4)=1, append → tails=[1,4]
- 2: bisect_left([1,4],2)=1, replace → tails=[1,2]
- 5: bisect_left([1,2],5)=2, append → tails=[1,2,5]
bisect_left finds the first tail >= num. Replacing that tail with a smaller value keeps the door open for future elements to extend further. The invariant holds: tails[i] is always the minimum possible tail for any increasing subsequence of length i+1.
Two approaches work. When the problem guarantees the LIS starts before it ends, duplicate the array and run LIS on each suffix. For harder cases, use DP: compute the longest chain reaching each position from every starting point, then take the maximum. A concrete example: if you need the max points you can hit on a circle, convert positions to angles, sort them, then run LIS on the sorted angles.
Maintain two arrays during construction: tails for values and tail_positions for the original indices. After processing all elements, the last tail position marks the LIS end. Backtrack from there, finding the rightmost match in tail_positions that satisfies the increasing order constraint. This gives you one valid LIS in O(n log n). The O(n²) DP approach is simpler if you need the subsequence—sometimes clarity beats speed.
Cover these: empty input (returns 0), one element (returns 1), already sorted (returns n), all decreasing (returns 1), all identical values (strict LIS returns 1, non-strict returns n), alternating up-down patterns, extreme values in coordinate compression, and negatives. The algorithm handles all of them correctly—only the bisect_left vs bisect_right choice changes.
Dilworth's theorem says any partially ordered set has the size of its largest antichain equal to the minimum chain count needed to partition it. Applied to LIS: the longest increasing subsequence length equals the minimum number of decreasing subsequences covering the array. Patience sorting builds exactly this decomposition—each pile is a decreasing subsequence. This is why the algorithm works, not just that it works.
Use O(n²) DP when you need the actual subsequence, when you need to count distinct LIS (requires a Fenwick tree for O(n log n)), when n is under a few thousand and readability matters, or when your DP state has extra constraints per position. The O(n log n) algorithm gives length only—getting the subsequence or counts adds bookkeeping that can match or exceed O(n²) complexity.
Patience sorting binary-searches a simple array—cache-friendly for sequential access, low constant factor. A Fenwick tree needs coordinate compression and handles "max DP value for all values less than x" queries more naturally. Fenwick trees win when you need counting, range minimum queries, or 2D LIS. Patience sorting wins for simplicity and speed when you only need length.
Process each element as it arrives: binary search its position in the tails array and update. To reconstruct the subsequence later, also track predecessor indices. Memory usage is O(n) for the element history; update time is O(log n) per element. This works well in real-time systems where you need LIS length as data flows in.
Answer: n minus the LIS length. You can only keep elements that form an increasing subsequence, so the max you can keep is the LIS. The problem of "minimum deletions to make it increasing" reduces directly to LIS—you don't need a separate deletion algorithm.
Dilworth's theorem says the answer equals the LIS length. Run LIS on the original array—that's your partition count. To actually partition, use the greedy patience sorting approach: for each element, place it on the leftmost pile where it fits (non-increasing means each element <= pile tail). The pile count matches the LIS length.
Further Reading
- Dynamic Programming: States and Transitions — Learn how to design DP states for LIS-like problems
- Russian Doll Envelopes (LeetCode 354) — Classic 2D LIS application
- Number of Longest Increasing Subsequence (LeetCode 673) — Count distinct LIS using Fenwick tree
- Patience Sorting on Wikipedia — Detailed explanation of the card game analogy
- Longest Increasing Subsequence on CP-Algorithms — Comprehensive algorithmic treatment
- Minimum Number of Operations to Make Array Increasing (LeetCode 1964) — Variation requiring LIS thinking
Conclusion
The key to O(n log n) LIS is the tails array: tails[i] holds the smallest ending value of any increasing subsequence of length i+1. Use bisect_left for strictly increasing sequences. When you need the actual subsequence, track positions during construction and backtrack from the end. For box-stacking and envelope problems, sort one dimension and apply LIS to the other. For more on optimization tricks, see Dynamic Programming: States and Transitions.
Category
Related Posts
1D Dynamic Programming Problems: Classic Patterns
Learn to solve common 1D dynamic programming problems including climbing stairs, house robber, and coin change with optimized space solutions.
2D Dynamic Programming: Matrix Chain and Beyond
Master 2D DP problems with two state variables for string manipulation, matrix chain multiplication, and optimal game strategies.
DP on Trees and Graphs: Tree DP and Graph DP
Apply dynamic programming to tree and graph structures using DFS-based state propagation for optimal subtree and path calculations.