考題:最長遞增子序列
經典的 O(n²) 動態規劃,對上用二分搜尋維護「每種長度的最小結尾」的 O(n log n) 做法。
解法
輸入
題目:找出最長的「嚴格遞增子序列」(可以不連續)。例如 10, 9, 2, 5, 3, 7, 101, 18 的答案長度是 4,像是 2, 3, 7, 18。
1/9
| a | 10 | 9 | 2 | 5 | 3 | 7 | 101 | 18 |
|---|---|---|---|---|---|---|---|---|
| i | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
tails:每種長度目前最小的結尾
- 10長 1
目前的元素接得上/答案取代一個結尾
10 比 tails 裡每個結尾都大:接在最長的後面,長度變成 1。
兩種解法跑同一份輸入
| 長度 | 找到的子序列 | 比較次數 | 額外記憶體(個數) | Big O | |
|---|---|---|---|---|---|
| 動態規劃 | 4 | 2, 5, 7, 101 | 28 | 16 | O(n²) |
| 二分搜尋 | 4 | 2, 3, 7, 18 | 10 | 12 | O(n log n) |
兩種解法的長度一定相同;同樣長的答案不只一個時,找到的子序列可能不同。
工作量只計值的比較,不含記憶體配置或答案重建。兩種解法都沿前驅追加元素、最後反轉一次:答案長度 L 的重建為 O(L)。
亮起來的是這一步執行的程式碼
function lisDp(a: number[]): number[] { const n = a.length; const dp = new Array(n).fill(1); const prev = new Array(n).fill(-1); for (let i = 0; i < n; i++) { for (let j = 0; j < i; j++) { if (a[j] < a[i] && dp[j] + 1 > dp[i]) { dp[i] = dp[j] + 1; prev[i] = j; } } } let best = 0; for (let i = 1; i < n; i++) if (dp[i] > dp[best]) best = i; const out: number[] = []; for (let k = n ? best : -1; k !== -1; k = prev[k]) out.push(a[k]); out.reverse(); return out;} function lisTails(a: number[]): number[] { const tails: number[] = []; // index of the smallest ending per length const parent = new Array(a.length).fill(-1); for (let i = 0; i < a.length; i++) { let lo = 0, hi = tails.length; while (lo < hi) { const mid = (lo + hi) >> 1; if (a[tails[mid]] < a[i]) lo = mid + 1; else hi = mid; } parent[i] = lo > 0 ? tails[lo - 1] : -1; if (lo === tails.length) tails.push(i); else tails[lo] = i; } const out: number[] = []; for (let k = tails.length ? tails[tails.length - 1] : -1; k !== -1; k = parent[k]) out.push(a[k]); out.reverse(); return out;}模型假設與範圍
- Big O 與實測步數以頁面列出的基本操作為單位;視覺化的快照、畫圖、程式高亮和輸出成本另計,不是執行時間 benchmark。
什麼時候用
- 面試時先講 O(n²) 的動態規劃:定義 dp[i] 為「以 a[i] 結尾的最長長度」,這是面試官想先聽到的思路。
- 被追問「能更快嗎」再拿出 tails +二分搜尋,並說清楚 tails 不是答案本身,只有長度是;要答案得另外記前一個。
和其他主題的關係
時間與空間複雜度(Big O)
| 操作 | 平均 | 最差 |
|---|---|---|
| 動態規劃 每個 i 都回頭看前面所有 j | O(n²) | O(n²) |
| tails +二分搜尋 每個元素在長度最多 n 的 tails 裡二分搜尋一次 | O(n log n) | O(n log n) |
| 重建子序列 沿著前一個的連結走回去,L 是答案長度 | O(L) | O(n) |
空間:O(n),兩種解法都要每個元素一個「前一個」連結才能重建答案
Big O 實測:n 變大時步數怎麼長
數的是:比較次數(隨機輸入)
| Big O | n = 250 | n = 500 | n = 1,000 | n = 2,000 | 成長倍數:實測(理論) | |
|---|---|---|---|---|---|---|
| 動態規劃 | O(n²) | 31,125 | 124,750 | 499,500 | 1,999,000 | ×64 (×64) |
| tails +二分搜尋 | O(n log n) | 1,008 | 2,268 | 5,101 | 11,087 | ×11 (×11) |
和其他做法比
| LIS 長度 | 動態規劃:比較 | 二分搜尋:比較 | 差幾倍 | |
|---|---|---|---|---|
| n = 100 | 16 | 4,950 | 344 | ×14 |
| n = 1,000 | 48 | 499,500 | 5,033 | ×99 |
| n = 3,000 | 74 | 4,498,500 | 17,068 | ×264 |
隨機輸入(1–99 之間的整數),兩種解法跑同一份資料、算出同樣的長度。動態規劃每一對 (j, i) 都要比一次,所以剛好是 n(n−1)/2;二分搜尋每個元素只在 tails 裡找 log 次。這裡計比較次數,不是實際耗時;不含記憶體配置及 O(L) 的答案重建。
真實世界裡的它
- 變形:非嚴格遞增(允許相等)時,二分搜尋改找「第一個大於 x」而不是「大於等於」。
- 俄羅斯套娃信封(Russian doll envelopes):先依寬度排序、同寬時高度遞減,再對高度做 LIS(Longest Increasing Subsequence,最長遞增子序列)。
- 耐心排序(patience sorting)就是 tails 方法的來源:每一疊牌的頂端就是 tails 的一格。
取捨與陷阱
- 把「子序列」當成「子陣列」:子序列可以不連續,子陣列必須連續,解法完全不同。
- 二分搜尋用錯邊界:嚴格遞增要找「≥ x」的第一個位置,用成「> x」就會把相等的值算進去。
- 直接回傳 tails 當答案:它的長度對,但內容可能根本不是原陣列裡的一個子序列。