跳到主要內容

演算法

同一個問題,不同的解題思路

主題 · 考題:最長遞增子序列

考題:最長遞增子序列

經典的 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
i01234567
tails:每種長度目前最小的結尾
  1. 10
    長 1
目前的元素接得上/答案取代一個結尾

10 比 tails 裡每個結尾都大:接在最長的後面,長度變成 1。

兩種解法跑同一份輸入
長度找到的子序列比較次數額外記憶體(個數)Big O
動態規劃42, 5, 7, 1012816O(n²)
二分搜尋42, 3, 7, 181012O(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 On = 250n = 500n = 1,000n = 2,000成長倍數:實測(理論)
動態規劃O(n²)31,125124,750499,5001,999,000×64 (×64)
tails +二分搜尋O(n log n)1,0082,2685,10111,087×11 (×11)

和其他做法比

LIS 長度動態規劃:比較二分搜尋:比較差幾倍
n = 100164,950344×14
n = 1,00048499,5005,033×99
n = 3,000744,498,50017,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 當答案:它的長度對,但內容可能根本不是原陣列裡的一個子序列。

LeetCode 練習