LIS 是研究所考試常見的題目,但參考書提供的考試寫法在實際使用上有一些限制,在這題直接使用會 WA 。在這裡以LeetCode 300. Longest Increasing Subsequence 為例,紀錄了時間複雜度從 O(n2) 到 O(nlogn) 的三種解法,並在題目要求外,額外添加了還原 LIS 的過程。
由於題目要求的是 LIS 的長度,因此還原部分的程式碼未經完全測試,僅供參考。若有錯誤之處,還請斧正。
方法一:視為LCS問題後,Dynamic Programming (in Textbook)
這裡使用了在 CLRS Exercise 14.4-5
的 Solution 中給出的方法:
- 令 X 為原序列,Y 為原序列排序後所得的序列,
- X 和 Y 的
Longest Common Subsequence (LCS)
,就是 X 的 Longest Increasing Subsequence (LIS)
。
- 這裡把 X 擺在 左邊,Y 擺在 上面,方便理解。
- 但直接使用這種方法,若遇到數字重複的的情況,就會出錯,因此要先對 Y 去重 。
- 紀錄LCS時,使用了兩個二維陣列, dp 表示 LCS 的長度, label 表示
LCS
的轉移來源。
- 若 X[i−1]==Y[j−1],則 dp[i][j]=dp[i−1][j−1]+1,且 label[i][j]="↖"。代表 X[i−1] 和 Y[j−1] 相等,可以接在 X[:i−1] 和 Y[:j−1] 的
LCS
的末尾。
- 若 X[i−1]=Y[j−1],則 dp[i][j]=max(dp[i−1][j],dp[i][j−1]),且 label[i][j]="↑" 或 label[i][j]="←"。代表不相等,則各退一步,取最大值。
- 在重購 LCS 時,從 dp 中找到最長的
LCS
長度,並依照 label 的方向,找到轉移來源,即可重構出對應的 LCS
。
- 時間複雜度:O(n2),其中排序的時間複雜度為 O(nlogn),
LCS
的時間複雜度為 O(n2)。
- 空間複雜度:O(n2), dp 和 label 各佔一個 n×m 的二維陣列。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| class Solution: def lengthOfLIS(self, nums: List[int]) -> int: Y = sorted(list(set(X))) n, m = len(X), len(Y)
dp = [[0] * (m+1) for _ in range(n+1)] label = [[""] * (m+1) for _ in range(n+1)] for i in range(1, n+1): for j in range(1, m+1): if X[i-1] == Y[j-1]: dp[i][j] = dp[i-1][j-1] + 1 label[i][j] = "↖" elif dp[i-1][j] >= dp[i][j-1]: dp[i][j] = dp[i-1][j] label[i][j] = "↑" else: dp[i][j] = dp[i][j-1] label[i][j] = "←" res = [-1] * dp[n][m] i, j = n, m while i > 0 and j > 0: if label[i][j] == "↖": res[dp[i][j]-1] = X[i-1] i -= 1 j -= 1 elif label[i][j] == "↑": i -= 1 else: j -= 1 print(res) return dp[n][m]
|
方法二:直接做 Dynamic Programming
相對直覺,可以從「求 LIS
長度」直接想到的方法。
- 令 dp[i] 表示以 nums[i] 結尾的
LIS
長度;令 prev[i] 表示以 nums[i] 結尾的 LIS
的前一個位置。
- 則 dp[i]=max(dp[i],dp[j]+1) for j∈[0,i) if nums[j]<nums[i]。
- 即枚舉所有位置 i 以及 i 前面的所有位置 j,若 nums[j]<nums[i],則 nums[i] 可以接在 nums[j] 後面,並構成更長的
LIS
,更新 dp[i] 和 prev[i] 。
- 在重購
LIS
時,從 dp 中找到最長的 LIS
長度,並從 prev 中重構出對應的 LIS
(由後往前)。
- 時間複雜度:O(n2)
- 空間複雜度:O(n), dp 和 prev 各佔一個長度為 n 的陣列。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| class Solution: def lengthOfLIS(self, nums: List[int]) -> int: n = len(nums) prev = [-1] * n dp = [1] * n for i in range(1, n): for j in range(i): if nums[j] < nums[i]: if dp[j] + 1 > dp[i]: dp[i] = dp[j] + 1 prev[i] = j
max_len = max(dp) res = [-1] * max_len for i in range(n-1, -1, -1): if dp[i] == max_len: cur = i idx = max_len - 1 while cur != -1: res[idx] = nums[cur] cur = prev[cur] idx -= 1 break print(res) return max_len
|
方法三:Greedy + Binary Search
CLRS Exercise 15.4-6⋆
要求的 O(nlogn) 時間複雜度的演算法,即 Robinson-Schensted-Knuth Algorithm
。
- 如果已經得到的
LIS
結尾的數越小,那麼在後面接上其他數,會有更大的可能構成一個長度更長的 LIS
。也就是說,在長度固定的情況下,結尾越小越好。根據這個貪心思路,可以記錄以下資訊:
- tail[i] 表示 長度為 i+1 的 LIS 的最後一個元素的 最小值 ,初始化 tail[0] = nums[0]
- 需要注意的是,tail 並不是LIS ,只是用來計算 LIS 長度的輔助陣列
- pos[i] 紀錄 nums[i] 在 LIS 中的第幾個位置
- 遍歷所有元素時,若 nums[i]>tail[−1] ,則可以構成更長的 LIS,將其接在 tail 的末尾,並更新 pos[i]。
- 否則,在 tail 中二分查找,找到第一個大於等於 nums[i] 的元素,並將其更新為 nums[i]。
- 重構時,從 pos 中找到最後一個元素,並從後往前重構 LIS。
- 時間複雜度:O(nlogn),遍歷 n 個元素,每次二分查找的時間複雜度為 O(logn)。
- 空間複雜度:O(n), tail 和 pos 各佔一個長度為 n 的陣列。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| class Solution: def lengthOfLIS(self, nums: List[int]) -> int: n = len(nums)
tail = [nums[0]] pos = [0] * n
for i in range(1, n): if nums[i] > tail[-1]: tail.append(nums[i]) pos[i] = len(tail) - 1 continue
left = 0 right = len(tail) - 1 while left <= right: mid = (left + right) // 2 if tail[mid] < nums[i]: left = mid + 1 else: right = mid -1 tail[left] = nums[i] pos[i] = left
res = [-1] * len(tail) j = len(tail) - 1 for i in range(n-1, -1, -1): if pos[i] == j: res[j] = nums[i] j -= 1 print(res) return len(tail)
|
參考資料