Longest Increasing Subsequence: Patience Sorting

Find the longest strictly increasing subsequence in O(n log n) time using patience sorting and binary search techniques.

published: reading time: 15 min read author: GeekWorkBench

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
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

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

FailureCauseMitigation
Wrong binary search variantUsing bisect_right instead of bisect_leftUse bisect_left for strictly increasing, bisect_right for non-decreasing
Off-by-one in reconstructionMismatched index trackingTest carefully with duplicate values
Strict vs non-strict confusion”Increasing” vs “non-decreasing”Clarify problem: strictly increasing (<) or non-decreasing (<=)
Negative numbersNot handling negative input correctlyAlgorithm works for any comparable elements

Common Pitfalls / Anti-Patterns

  1. Confusing bisect_left vs bisect_right — For strict increasing, use bisect_left; for non-decreasing (allow equal), use bisect_right
  2. Not handling empty input — LIS of empty array is 0
  3. Reconstruction complexity — The O(n log n) algorithm finds length only; reconstruction requires additional tracking
  4. Multiple valid LIS — Algorithm returns one valid LIS, not necessarily the lexicographically smallest

Trade-off Analysis

ApproachTime ComplexitySpace ComplexityFinds LengthReconstructs LISCounts LIS
O(n²) DPO(n²)O(n)YesYesYes
O(n log n) Patience SortingO(n log n)O(n)YesWith trackingNo
O(n log n) Fenwick TreeO(n log n)O(n)YesYesYes

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

1. Why does the tails array always stay sorted?

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.

2. How does LIS relate to patience sorting?

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.

3. Can you find LIS in O(n log n) for 2D problems like Russian Doll Envelopes?

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.

4. What is the difference between LIS and Longest Common Subsequence (LCS)?

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.

5. How would you find the Longest Decreasing Subsequence (LDS)?

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.

6. How does the O(n²) DP solution for LIS work?

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
7. What is the space complexity of the O(n log n) LIS algorithm?

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.

8. How would you find the number of longest increasing subsequences (LeetCode 673)?

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]: update length[i] = length[j] + 1, count[i] = count[j]
  • If length[j] + 1 == length[i]: add count[j] to count[i]

Sum counts for all indices with maximum length. For O(n log n), use a Fenwick tree over coordinate-compressed values.

9. What happens when the input has duplicate elements in the LIS algorithm?

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.

10. Can LIS be solved using segment trees or Fenwick trees?

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.

11. Walk through a complete example of the binary search step-by-step on tails array.

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.

12. How do you handle LIS when dealing with a circular array or cyclic behavior?

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.

13. How would you reconstruct the actual LIS subsequence from the O(n log n) algorithm?

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.

14. What edge cases should you test for a production LIS implementation?

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.

15. What is the connection between LIS and Dilworth's Decomposition Theorem?

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.

16. When would you choose O(n²) DP over O(n log n) patience sorting for LIS?

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.

17. How does patience sorting compare to a Fenwick tree for the O(n log n) LIS approach?

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.

18. How would you adapt LIS for a streaming scenario where elements arrive one at a time?

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.

19. What happens to LIS when the problem asks for the minimum number of deletions to make the array strictly increasing?

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.

20. How do you modify LIS to handle the "minimum number of non-increasing subsequences" partition problem?

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

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.

#dynamic-programming #dp #1d-dp

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.

#dynamic-programming #dp #2d-dp

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.

#dynamic-programming #tree-dp #graph-dp