DSA DSA In Depth
0%
lesson lispatience-sortingdilworth-theorem

Longest Increasing Subsequence (LIS): O(N^2) DP vs O(N log N) Patience Sorting & Dilworth's Theorem

7 min read Module 6.2: Dynamic Programming Foundations & Classical Archetypes Updated Oct 23, 2026
📄 Cheat Sheet
Patience Sorting and Longest Increasing Subsequence in O(N log N)
Stable Sort Patience Sorting and Longest Increasing Subsequence in O(N log N) Watch on YouTube ↗

Visualizes card piles in Patience Sorting, why pile tops stay sorted, and how binary search finds the exact LIS length and reconstructs the sequence.

0:00 / --:--

1. State Inversion: From $O(N^2)$ Prefix DP to $O(N \log N)$ Monotonic Tails

The textbook $O(N^2)$ recurrence defines $dp[i]$ as the length of the Longest Increasing Subsequence ending at $A[i]$: $dp[i] = 1 + \max_{j < i, A[j] < A[i]} dp[j]$. To break the $O(N^2)$ barrier, we apply State-Value Inversion:

Define $\text{tails}[L]$ (0-indexed for length $L + 1$) as the minimum possible ending value among all strictly increasing subsequences of length $L + 1$ seen so far in prefix $A[0..i]$.

Core Invariant — Strict Monotonicity of the Patience Sorting tails Array

After processing any prefix $A[0..i]$, the array $\text{tails}$ satisfies:

$$\text{tails}[0] < \text{tails}[1] < \dots < \text{tails}[k - 1]$$

Proof: If $\text{tails}[L] \le \text{tails}[L-1]$ were ever true, then taking the second-to-last element of the length-$(L+1)$ subsequence ending in $\text{tails}[L]$ would give a valid increasing subsequence of length $L$ with ending value $< \text{tails}[L] \le \text{tails}[L-1]$, contradicting the minimality of $\text{tails}[L-1]$!
Because $\text{tails}$ is sorted, inserting $x = A[i]$ replaces the first element $\ge x$ via std::lower_bound (for strictly increasing $<$) or std::upper_bound (for non-decreasing $\le$) in $O(\log N)$ time.

2. Patience Sorting & Dilworth's Theorem (Chain / Antichain Duality)

Imagine dealing cards $A[0..n-1]$ into piles from left to right according to Patience Sorting (Aldous & Diaconis, 1999): place card $x$ on the leftmost pile whose top card is $\ge x$ (or start a new pile to the right if all pile tops are $< x$). Each pile is a non-increasing subsequence, and the top cards of the piles from left to right are literally our array $\text{tails}[0..k-1]$!

  • Dilworth's Theorem (1950): In any finite partially ordered set, the minimum number of disjoint chains (non-increasing subsequences) needed to partition the sequence equals the maximum size of an antichain (strictly increasing subsequence). Thus:
    • Minimum number of non-increasing subsequences to cover $A$ $=$ Length of Strictly Increasing LIS (lower_bound).
    • Minimum number of strictly decreasing subsequences to cover $A$ $=$ Length of Non-Decreasing LNDS (upper_bound).
  • 2D Envelope / Russian Doll Nesting $(w_i, h_i)$: To find the longest chain where both $w_a < w_b$ and $h_a < h_b$, sort pairs by $w_i$ ascending, and on ties by $h_i$ DESCENDING, then run 1D strictly increasing LIS on $h_i$! Sorting ties by $h_i$ descending guarantees we can never pick two envelopes with the same width $w$.
flowchart LR subgraph Patience["Patience Sorting Piles on A = [4, 10, 4, 3, 8, 9]"] P1["Pile 0 (L=1):
4 -> 4 -> 3
Top = 3"] --> P2["Pile 1 (L=2):
10 -> 8
Top = 8"] P2 --> P3["Pile 2 (L=3):
9
Top = 9"] end P3 --> Result["LIS Length = 3 (Number of Piles)
3 Non-Increasing Chains Partition A:
[4, 4, 3], [10, 8], [9]"]

Hardware Reality — Why tails Alone Is NOT the LIS (Reconstruction via Index Tracking)

A notorious interview and production bug is returning the final tails vector itself as the LIS! On $A = [10, 20, 30, 5]$, after processing $30$ we have tails = [10, 20, 30], and when $5$ arrives it overwrites tails[0] = 5, leaving tails = [5, 20, 30]—which is not a valid subsequence because $5$ appeared after $20$ and $30$! To reconstruct the true sequence in $O(N \log N)$ time, store the array index tail_idx[pos] = i and record parent[i] = (pos > 0 ? tail_idx[pos - 1] : -1) at the moment $A[i]$ is placed at position pos.

3. Complexity Matrix

DP State DAG, 0/1 Knapsack Reverse Sweep & Myers Diff
O(N·W) / O(N·D) Edit Graph

Observe why 0/1 Knapsack iterates capacity w downwards (preventing item reuse), how Patience Sorting finds LIS in O(N log N), and how Myers Diff slides along diagonal snakes k = x - y.

Step 1 / 8
Custom Input:
Timeline

Invariant & State

Item 1 (wt=3, val=4): Sweep w = 7 downTo 3 so dp[w - 3] always reads the previous item layer.
🔗 Live Pointer & State Transition Graph — DP State DAG, 0/1 Knapsack Reverse Sweep & Myers Diff Active Pointer Jump/Transition Matched/Optimal
w=0 0 [0] 0 [1] 0 [2] wt=3, v 4 [3] 4 [4] 4 [5] 4 [6] dp[7]=4 4 [7]
[0]0w=0
[1]0
[2]0
[3]4wt=3, val=4
[4]4
[5]4
[6]4
[7]4dp[7]=4
Item 1
weight=3, value=4
Sweep Direction
w = 7 → 3 (Reverse)
Best dp[7]
4
[Step 1] Initialized 1D table with Item 1 (wt=3, val=4) across capacities 3..7.
LIS Variant / Algorithm Binary Search Comparator Time Complexity Auxiliary Space Output Capability
Naive Prefix DP ($dp[i]$) Linear Scan $j < i$ O(N^2) O(N) Length + Count + Sequence
Patience Sorting (Strictly Increasing $<$) std::lower_bound ($ge x$) O(N log N) O(N) Length + Reconstructed LIS
Patience Sorting (Non-Decreasing $le$) std::upper_bound ($> x$) O(N log N) O(N) Length + Reconstructed LNDS
Count of All LIS / Weighted LIS Fenwick / Segment Tree on ranks O(N log N) O(N) Max Weighted LIS + Exact Count

4. Reference C++20 Implementation

#include <algorithm>
#include <cstdint>
#include <vector>

// 1. O(N log N) Strictly Increasing Subsequence with Full Sequence Reconstruction
std::vector<int> longest_increasing_subsequence(const std::vector<int>& nums) {
    const int n = static_cast<int>(nums.size());
    if (n == 0) return {};

    std::vector<int> tails;          // tails[len] = smallest ending value of LIS of length len+1
    std::vector<int> tail_idx;       // tail_idx[len] = index in nums that achieved tails[len]
    std::vector<int> parent(n, -1);  // parent[i] = predecessor index in nums for element i

    for (int i = 0; i < n; ++i) {
        auto it = std::lower_bound(tails.begin(), tails.end(), nums[i]);
        int pos = static_cast<int>(it - tails.begin());

        if (it == tails.end()) {
            tails.push_back(nums[i]);
            tail_idx.push_back(i);
        } else {
            *it = nums[i];
            tail_idx[pos] = i;
        }
        if (pos > 0) {
            parent[i] = tail_idx[pos - 1];
        }
    }

    // Reconstruct valid subsequence backward from the index of the longest pile top
    std::vector<int> result(tails.size());
    int curr = tail_idx.back();
    for (int k = static_cast<int>(tails.size()) - 1; k >= 0; --k) {
        result[k] = nums[curr];
        curr = parent[curr];
    }
    return result;
}

// 2. 2D Russian Doll Envelopes: Sort (w ASC, h DESC) + 1D LIS on h
int max_nested_envelopes(std::vector<std::pair<int, int>> envelopes) {
    std::sort(envelopes.begin(), envelopes.end(), [](const auto& a, const auto& b) {
        if (a.first != b.first) return a.first < b.first;
        return a.second > b.second; // DESCENDING height on equal width prevents duplicate width picks!
    });

    std::vector<int> tails;
    for (const auto& [w, h] : envelopes) {
        auto it = std::lower_bound(tails.begin(), tails.end(), h);
        if (it == tails.end()) tails.push_back(h);
        else *it = h;
    }
    return static_cast<int>(tails.size());
}

Production Pitfall — Using lower_bound Instead of upper_bound for Non-Decreasing Subsequences

When a problem asks for the Longest Non-Decreasing Subsequence ($A[i_1] \le A[i_2] \le \dots$), equal elements are allowed to extend an existing chain! If you use std::lower_bound ($ge x$), an equal element overwrites the existing tail instead of extending it. Always remember the pairing: Strictly Increasing ($<$) $\iff$ lower_bound; Non-Decreasing ($le$) $\iff$ upper_bound.

Check yourself Question 1 of 4
Concept Check Strictly Increasing vs. Non-Decreasing LIS Binary Search recall

In the $O(N \log N)$ Patience Sorting algorithm maintaining the monotonic `tails` vector, which C++ standard library binary search functions should be used to find the replacement position for element `x` in (1) **Strictly Increasing** ($<$) vs. (2) **Longest Non-Decreasing** ($\le$) Subsequence?

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

Analyze the invariant, asymptotic bound, and hardware behavior before selecting.

To find the maximum number of nested 2D envelopes $(w_i, h_i)$ where strictly $w_a < w_b$ and $h_a < h_b$, we sort the envelopes by width $w_i$ **ascending**, and on width ties ($w_a = w_b$) by height $h_i$ **descending**, then run 1D strictly increasing LIS on the heights $h_i$. Why is sorting equal-width ties by $h_i$ descending mandatory?

Key Takeaways

Patience Sorting inverts the DP state: tails[L] stores the minimum ending value of an increasing subsequence of length L+1.
Because tails is strictly increasing, each element updates a single entry via std::lower_bound in O(log N) time, achieving O(N log N) overall.
The tails array itself is not necessarily a valid subsequence; reconstructing the actual LIS requires tracking tail_idx[pos] and parent[i] at insertion time.
By Dilworth's Theorem, the minimum number of non-increasing subsequences needed to partition an array equals the length of its strictly increasing LIS.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue