Patience Sorting — 以 O(N log N) 求 LIS

搜尋與排序優先度 4/5 — 高價值 — 這裡有缺口就會掉關高價值 更新於 Oct 9, 2026
章節優先度優先度 5/5 — 必備 — 幾乎每一輪面試都會出現必備優先度 4/5 — 高價值 — 這裡有缺口就會掉關高價值優先度 3/5 — 值得會 — 多半是必備模式的變形值得會優先度 2/5 — 冷門 — 讀過一次即可,除非目標公司已知會問冷門只標在真正需要的章節上 —— 沒標的是背景/參考資料。

範圍 — O(N log N) 最長遞增子序列背後的紙牌遊戲演算法:牌堆、牌堆收斂成的 tails 陣列、如何還原子序列本身,以及可以歸約到它的題目家族。 另見:binary_search.md §1.5 — 這個掃描所依賴的 lower-bound 模板,以及「每個元素寫入一次就是完整的 DP 更新」的證明;binary_search_examples.md §18 — 完整解過的 LC 300 / LC 354;dp_pattern.md — 本法所取代的 O(n²) LIS DP,以及那些無法被取代的 LIS 形 DP;sort.md — 相鄰的排序演算法。

  • 核心概念:把陣列當成一局接龍(patience)發牌 — 每張牌放到最左邊、頂牌 >= card 的牌堆上,沒有這種牌堆就開一個新牌堆 — 而牌堆的數量就是 LIS 的長度
  • 何時使用:求最長的遞增/可串接序列,只需要它的長度,而 O(n²) DP 太慢
  • 關鍵 LeetCode 題目:LC 300、LC 334、LC 354、LC 1964、LC 2111、LC 1713、LC 1671
  • 資料結構:一個已排序的牌堆頂陣列(tails);若需要子序列本身,再加上父指標
  • 典型狀態:tails[k] = 長度為 k + 1 的遞增序列所能擁有的最小結尾值

時間複雜度: O(N log N) — N 張牌 × 每張在至多 N 個牌堆頂上做一次二分搜尋 空間複雜度: O(N)

實作:algorithm/python/patience_sorting.py — 涵蓋下方全部五種形式,並以隨機測試與 O(n²) DP 交叉驗證。

LeetCode 題目清單

0) 概念

0-0) 核心原理 — 把陣列發成牌堆 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

三條規則,由左到右作用在輸入上:

  1. 把牌放到最左邊、頂牌 >= card 的牌堆上。
  2. 如果沒有符合的牌堆,就在右邊開一個新牌堆。
  3. 發完牌時,牌堆的數量就是 LIS 的長度。
text
nums = [10, 9, 2, 5, 3, 7]

10 -> new pile           | 10 |
 9 -> 10 >= 9, pile 0    | 10 9 |
 2 ->  9 >= 2, pile 0    | 10 9 2 |
 5 ->  2 <  5, new pile  | 10 9 2 | 5 |
 3 ->  5 >= 3, pile 1    | 10 9 2 | 5 3 |
 7 ->  3 <  7, new pile  | 10 9 2 | 5 3 | 7 |

3 piles -> LIS length 3   (e.g. [2, 3, 7])

有兩個事實讓這成為演算法,而不只是紙牌把戲:

  • 牌堆頂由左到右遞增。 落在某牌堆上的牌小於或等於該堆的頂牌,且嚴格大於左邊那堆的頂牌;開新牌堆的牌則大於所有頂牌。無論哪種情況,頂牌都維持有序 — 也就是說「最左邊、頂牌 >= x 的牌堆」就是單純的 lower_bound(bisect_left),成本是 O(log P) 而不是 O(P)。
  • 只有頂牌會被讀取。 把牌堆收斂成各自的頂牌,就得到面試時大家寫的 tails 陣列:
text
piles  | 10 9 2 | 5 3 | 7 |
tops   [   2,     3,    7 ]   ==  tails
                                  tails[k] = smallest tail of a run of length k+1

0-1) 類型

  1. 只求長度 — 單純的掃描,答案是 len(tails)(LC 300)
  2. 每個索引的長度 — 邊掃邊回報插入位置,每個元素一個答案(LC 1964)
  3. 子序列本身 — 同樣的掃描加上父指標(§1-4)
  4. 二維,先排序 — 對一個維度排序,讓另一個維度變成一維 LIS(LC 354)
  5. 歸約 — 把另一個問題改寫成 LIS:其中一邊元素互異的 LCS、k 條交錯的序列(LC 1713、LC 2111)
  6. 非遞減而非嚴格遞增 — 只差一個字元,改用 bisect_right(§1-2)

0-2) 為什麼牌堆數就是 LIS 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

兩個方向,各一行,合起來就是完整的正確性證明:

text
LIS <= piles :  a pile read in DEALING ORDER (bottom -> top) is NON-INCREASING,
                so an increasing subsequence can take at most ONE card from each
                pile -> it cannot be longer than the number of piles

LIS >= piles :  a card on pile k landed there because pile k-1 already had a
                SMALLER top; chain that back pile by pile and you get an actual
                increasing subsequence of length k + 1
                -> the last pile witnesses a run as long as the pile count

從兩邊夾擠,piles == LIS。面試時點出它的名字是加分訊號:這就是 Dilworth 定理 — 牌堆構成陣列的一個最小非遞增覆蓋,而這種覆蓋的最小大小等於最長遞增子序列的長度。

同一件事用 tails 的語言來講 — 有序陣列的不變量,以及每個元素恰好只有一個格子 可能被改進的證明 — 在 binary_search.md §1.5。 牌堆觀點用來理解為什麼,tails 觀點用來寫程式碼。

0-3) 模式 — 什麼時候適用 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

當以下三個條件全部成立時,就用 patience sorting:

條件 為什麼重要
答案是一個長度(或 n − length),不是方法數,也不是加權總和 每個牌堆一個值只能承載「多長」,永遠無法承載「有幾種」或「有多少」
「可串接」是單一鍵上的全序 — 數字上的 <,或先排序過的某個鍵上的 < 牌堆頂必須概括到目前為止進度的一切
直覺解法是對 j < i 做 dp[i] = max(dp[j]) + 1,而你需要更快 內層那個 max 正是 lower bound 所取代的東西

一句話判別:tails 可行,當且僅當一條鏈上的進度能用一個可比較的數字概括,且你只想知道多長。

三個條件任一不成立,就退回 O(n²) DP 或 Fenwick tree — 見 §2-8 的陷阱表。

1) 通用形式

1-1) 基本操作 — tails 掃描 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

python
# python
# IDEA: patience sorting - tails[k] = smallest tail of an increasing run of
#       length k+1; lower-bound it to find the pile, then extend or overwrite
# time = O(n log n), space = O(n)
def lis_length(nums):
    tails = []

    for num in nums:
        # lower bound : first index with tails[l] >= num
        l, r = 0, len(tails) - 1
        while l <= r:
            mid = l + (r - l) // 2
            if tails[mid] < num:
                l = mid + 1      # NOTE !!! strict < -> an equal tail FAILS this test,
                                 #          so it takes the else and pushes r left
            else:
                r = mid - 1

        if l == len(tails):
            tails.append(num)    # beats every top -> a NEW pile, a longer run exists
        else:
            tails[l] = num       # same length, cheaper tail -> overwrite

    return len(tails)            # NOTE !!! the LENGTH is the answer, not the contents
python
# python - the same scan, with the library doing the search
# IDEA: bisect_left IS the lower bound written out above
# time = O(n log n), space = O(n)
import bisect

def lis_length_bisect(nums):
    tails = []
    for num in nums:
        l = bisect.bisect_left(tails, num)   # first tail >= num
        if l == len(tails):
            tails.append(num)
        else:
            tails[l] = num
    return len(tails)
java
// java
// IDEA: same scan on a fixed array - `size` is the pile count, so tails[0..size)
//       is the sorted window the lower bound runs on
// time = O(n log n), space = O(n)
public int lisLength(int[] nums) {
    int[] tails = new int[nums.length];
    int size = 0;

    for (int num : nums) {
        int l = 0, r = size;
        while (l < r) {                     // lower_bound over tails[0..size)
            int mid = l + (r - l) / 2;
            if (tails[mid] < num) l = mid + 1;
            else r = mid;
        }
        tails[l] = num;                     // overwrite ...
        if (l == size) size++;              // ... or extend, when l lands past the end
    }
    return size;
}

1-2) 嚴格遞增 vs 非遞減 — 只差一個字元

這個家族最常見的錯誤答案,就是用錯 bisect:

目標 在 tails 上的查詢 Python 遇到相等值的效果
嚴格遞增(LC 300) 第一個 >= num 的 tail bisect_left 落在相等的 tail 上 → 覆寫它,不增長
非遞減(允許重複) 第一個 > num 的 tail bisect_right 落在相等的 tail 之後 → 延長序列
非遞增/遞減 把輸入取負,再套上面的做法 對 -num 做 bisect_* —
python
# python - longest NON-DECREASING subsequence
# IDEA: identical scan, bisect_right so an equal value extends instead of replacing
# time = O(n log n), space = O(n)
import bisect

def lnds_length(nums):
    tails = []
    for num in nums:
        l = bisect.bisect_right(tails, num)   # first tail > num
        if l == len(tails):
            tails.append(num)
        else:
            tails[l] = num
    return len(tails)
text
nums = [2, 2, 2, 3]
bisect_left  -> tails [2, 3]           -> 2   (strictly increasing)
bisect_right -> tails [2, 2, 2, 3]     -> 4   (non-decreasing)

1-3) 保留牌堆

求長度時很少需要,但這才是演算法的原貌 — 也正是 patience sorting 之所以是一種排序的原因(之後把牌堆合併起來,就像 Timsort 合併 run 一樣):

python
# python
# IDEA: same placement rule, but append to the pile instead of only tracking its top
# time = O(n log n), space = O(n)
import bisect

def patience_piles(nums):
    piles = []       # piles[k] : the cards on pile k, top card LAST
    tops = []        # tops[k] == piles[k][-1], kept flat so bisect can search it

    for num in nums:
        l = bisect.bisect_left(tops, num)
        if l == len(piles):
            piles.append([num])
            tops.append(num)
        else:
            piles[l].append(num)
            tops[l] = num

    return piles     # len(piles) == LIS length; each pile is non-increasing
                     # bottom -> top, i.e. in the order it was dealt

1-4) 還原子序列,而不只是長度 優先度 4/5 — 高價值 — 這裡有缺口就會掉關

tails 不是一個子序列 — 只有它的長度有意義:

text
nums  = [3, 4, 5, 1]
tails = [1, 4, 5]      <- 1 sits at index 0 but arrives LAST in the input
len   = 3              <- correct anyway: the LIS is [3, 4, 5]

要交回真正的序列,就記住每張牌落下時接在誰後面:也就是放置當下它左邊那堆的 tail。

python
# python
# IDEA: same scan + two side arrays - the input index of each pile's top, and a
#       parent pointer per element; then walk back from the last pile's top
# time = O(n log n), space = O(n)
import bisect

def lis_reconstruct(nums):
    if not nums:
        return []

    tails = []                    # tail VALUES (the array bisect searches)
    tails_idx = []                # index in nums of each tail
    prev = [-1] * len(nums)

    for i, num in enumerate(nums):
        l = bisect.bisect_left(tails, num)

        # whatever currently ends the run of length l becomes num's predecessor
        if l > 0:
            prev[i] = tails_idx[l - 1]

        if l == len(tails):
            tails.append(num)
            tails_idx.append(i)
        else:
            tails[l] = num
            tails_idx[l] = i

    out = []
    i = tails_idx[-1]             # the top of the last pile ends a longest run
    while i != -1:
        out.append(nums[i])
        i = prev[i]
    return out[::-1]
  • 往回走得到的是某一條最長子序列,而不是標準唯一的一條: [10,9,2,5,3,7,101,18] 會得到 [2,3,7,18],因為 18 取代了 101 成為長度 4 的 tail。
  • 即使 tails_idx[l-1] 之後會被覆寫,prev[i] 仍然安全:它是在放置當下記錄的,那時該元素確實排在 nums[i] 之前。

1-5) 複雜度與真正會咬人的陷阱

時間 O(n log n) — 每個元素在至多 n 個頂牌上做一次 bisect
空間 tails 為 O(n);還原再多 O(n);保留完整牌堆總共 O(n)
最壞情況 遞增輸入會產生 n 個牌堆,遞減輸入只有 1 個 — 兩者都仍是 O(n log n)
  • ❌ 把 tails 當成答案子序列來讀(§1-4)。
  • ❌ 題目允許重複卻用 bisect_left,或不允許重複卻用 bisect_right(§1-2)。
  • ❌ 為了「幫忙」而先排序輸入。排序會破壞子序列所依據的順序 — 唯一合法的排序是 LC 354 裡那個刻意的排序,因為掃描接著跑的是第二個維度。
  • ❌ 問題問的是「有幾條」(LC 673)或「最大總和」(LC 2926)時還拿它來用 — 見 §2-8。

2) LC 範例

2-1) Longest Increasing Subsequence — LC 300 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

就是 §1-1 的單純掃描,答案是 len(tails)。O(n²) DP 是預期的第一個答案,O(n log n) 掃描則是追問;兩者完整的解說與 dry-run 表格在 binary_search_examples.md §18。

2-2) Increasing Triplet Subsequence — LC 334 優先度 4/5 — 高價值 — 這裡有缺口就會掉關

那個有名的雙變數技巧就是這個演算法,只是 tails 被限制在兩個牌堆:first/second 就是 tails[0]/tails[1],而「第三個牌堆將會開啟」就是答案。

python
# python
# IDEA: tails of size <= 2 - first/second ARE tails[0]/tails[1]; the moment a
#       value beats both, a third pile (an increasing triplet) exists
# time = O(n), space = O(1)
class Solution:
    def increasingTriplet(self, nums):
        first = second = float('inf')
        for num in nums:
            if num <= first:
                first = num          # tails[0] = a cheaper length-1 tail
            elif num <= second:
                second = num         # tails[1] = a cheaper length-2 tail
            else:
                return True          # would append tails[2] -> length 3 exists
        return False

不需要二分搜尋:只有兩個格子時,線性掃描就是 lower bound,這也是它看起來像另一個演算法、其實不是的原因。

2-3) Russian Doll Envelopes — LC 354 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

二維的 LIS。寬度遞增排序,寬度相同時高度遞減,然後只對高度做掃描。平手規則就是整道題的關鍵:高度遞減時,兩個寬度相同的信封永遠不可能同時被選中,因為後面那個的高度較小,無法接在前面那個之後。

程式碼 — 以及把同樣技巧用在 LC 1996 — 在 binary_search_examples.md §18。

2-4) Longest Valid Obstacle Course at Each Position — LC 1964 優先度 4/5 — 高價值 — 這裡有缺口就會掉關

掃描每一步其實都已經知道答案:一張牌落在哪個牌堆,就是以這張牌結尾的最長序列長度。把它回報出來,而不只回報最終的數量。

python
# python
# IDEA: non-decreasing variant (bisect_right); the landing index + 1 IS the
#       longest valid course ending at this obstacle
# time = O(n log n), space = O(n)
import bisect

class Solution:
    def longestObstacleCourseAtEachPosition(self, obstacles):
        tails, ans = [], []
        for h in obstacles:
            i = bisect.bisect_right(tails, h)   # heights may repeat -> bisect_right
            if i == len(tails):
                tails.append(h)
            else:
                tails[i] = h
            ans.append(i + 1)                   # NOTE !!! per-index answer, no extra pass
        return ans

2-5) Minimum Operations to Make the Array K-Increasing — LC 2111 優先度 4/5 — 高價值 — 這裡有缺口就會掉關

arr[i-k] <= arr[i] 只會牽連到模 k 同餘類中的索引,所以這個陣列其實是 k 條彼此獨立的序列。每條保留它的最長非遞減子序列,其餘全部替換掉。

python
# python
# IDEA: k independent slices arr[r::k]; keep each one's longest NON-DECREASING
#       subsequence (values may repeat) and pay 1 for every element kept out
# time = O(n log n), space = O(n)
import bisect

class Solution:
    def kIncreasing(self, arr, k):
        def lnds(seq):
            tails = []
            for x in seq:
                i = bisect.bisect_right(tails, x)
                if i == len(tails):
                    tails.append(x)
                else:
                    tails[i] = x
            return len(tails)

        return sum(len(arr[r::k]) - lnds(arr[r::k]) for r in range(k))

2-6) Minimum Operations to Make a Subsequence — LC 1713 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

這題值得一眼認出:它讀起來像 LCS,而 LCS 是 O(n·m) — 但 target 的值互異,所以把 arr 的每個值改寫成它在 target 中的位置,「公共子序列」就變成了「遞增子序列」。

python
# python
# IDEA: distinct target -> map arr values to target indices; a common subsequence
#       of (target, arr) is exactly an INCREASING subsequence of the mapped list
# time = O(n log n), space = O(n)
import bisect

class Solution:
    def minOperations(self, target, arr):
        pos = {v: i for i, v in enumerate(target)}   # value -> index in target

        tails = []
        for x in arr:
            if x not in pos:                          # not in target -> unusable
                continue
            p = pos[x]
            i = bisect.bisect_left(tails, p)          # strictly increasing indices
            if i == len(tails):
                tails.append(p)
            else:
                tails[i] = p

        return len(target) - len(tails)               # insert whatever the LIS missed

這個歸約需要互異性:有重複時,一個值會對應到多個索引,「遞增」就不再能刻畫「公共」。

2-7) Minimum Number of Removals to Make Mountain Array — LC 1671 優先度 4/5 — 高價值 — 這裡有缺口就會掉關

山形就是一段遞增序列和一段遞減序列在共同的山頂相接,所以把掃描跑兩次 — 正向求以 i 結尾的序列,反向求從 i 開始的序列。

python
# python
# IDEA: left[i] = LIS ending at i, right[i] = LIS starting at i (the same scan on
#       the reversed array); a peak needs both sides > 1, so keep the best sum - 1
# time = O(n log n), space = O(n)
import bisect

class Solution:
    def minimumMountainRemovals(self, nums):
        def lis_ending_at_each(seq):
            out, tails = [], []
            for x in seq:
                i = bisect.bisect_left(tails, x)     # strictly increasing
                if i == len(tails):
                    tails.append(x)
                else:
                    tails[i] = x
                out.append(i + 1)
            return out

        left = lis_ending_at_each(nums)
        right = lis_ending_at_each(nums[::-1])[::-1]

        # NOTE !!! a peak cannot be an endpoint -> both sides must exceed 1
        best = max(l + r - 1
                   for l, r in zip(left, right)
                   if l > 1 and r > 1)
        return len(nums) - best

2-8) 經典題一覽

直接用掃描解決(有時要先刻意排序):

LC # 題目 變化之處
300 Longest Increasing Subsequence 基準 — bisect_left,答案 len(tails)
334 Increasing Triplet Subsequence tails 限制為 2 → 兩個變數,O(1) 空間
354 Russian Doll Envelopes 以 (w asc, h desc) 排序,再對高度掃描
646 Maximum Length of Pair Chain 同樣是最長鏈問題;依結尾排序的貪婪比較簡單,但這個也行
435 Non-overlapping Intervals n − LC 646 的鏈長
1964 Longest Valid Obstacle Course at Each Position bisect_right,並在每一步回報落點索引
2111 Minimum Operations to Make the Array K-Increasing k 個同餘類,各自用非遞減變體
1671 Minimum Number of Removals to Make Mountain Array 掃描正向、反向各跑一次
1713 Minimum Operations to Make a Subsequence 一邊互異的 LCS → 對映射後索引做 LIS

近親 — 同樣是「對你維護的結構做二分搜尋」,但存的值是 DP 結果而不是 tail:LC 1235(Maximum Profit in Job Scheduling)、LC 1751(Maximum Number of Events That Can Be Attended II)、LC 981、LC 528 — 都在 binary_search_examples.md。

陷阱 — 長得像 LIS,但不是這個演算法:

LC # 題目 為什麼 tails 不行 改用
673 Number of Longest Increasing Subsequence 求的是數量而非長度 — 每個長度一個 tail 無法承載重數 帶平行計數陣列的 O(n²) DP,或在值域上用 BIT
368 Largest Divisible Subset 整除不是全序,沒有單一值能概括一條鏈 O(n²) DP + 父指標
1691 Maximum Height by Stacking Cuboids 串接需要三個維度都 <= — 同樣是非全序的失敗 每個長方體的維度各自排序、再排序整個清單,O(n²) DP
1027 Longest Arithmetic Subsequence 狀態是 (index, difference) 雜湊表 DP
1218 Longest Arithmetic Subsequence of Given Difference 鏈以值為鍵,而不是以順序 一個雜湊表,O(n)
2926 Maximum Balanced Subsequence Sum 最大化的是總和,所以每個長度的最佳值不是單一可比較的 tail 在前綴最大值上用 Fenwick tree

總結

  • 每次都是同樣三條規則:頂牌 >= x 的最左牌堆,否則開新牌堆,答案 = 牌堆數。把牌堆收斂成頂牌,就得到 tails 和一個 lower_bound。
  • 正確性只要兩行:牌堆是非遞增的,所以 LIS <= piles;一張牌沿著牌堆往回的鏈是遞增的,所以 LIS >= piles(Dilworth)。
  • 嚴格遞增用 bisect_left,非遞減用 bisect_right — 整個家族最常見的 bug。
  • 落點索引就是逐元素的資訊 — LC 1964、LC 1671 不需要第二趟。
  • 一個長度、一個全序、一個可比較的鍵。 少了任何一個,答案就是 O(n²) DP 或 Fenwick tree,而不是更聰明的 bisect。

參考資料