子序列問題
前言
筆者曾經參加頭條的面試,面試官在算法環節問的就是這個問題,首先問了我連續子序列的情形,之后改為一般子序列。一般面試算法都是這種循序漸進的方式,先給出一個簡單情形,接着將問題提升,並不斷優化。題目不一定很難,主要考核面試者的應變和分析能力。可惜當時對算法理解還是過於淺顯,作為總結寫下這篇文章。
連續子列問題
最大子序和
問題描述:
求給定序列的所有連續子序列的最大和
分析
如果一個連續的子序列它的頭部和負(即以起點開始的連續子列),則將頭部丟去后得到的序列和更大。因此,每當有頭部和負時,置總和為0,表示從新開始累計求和,更新總和后也同時更新最大和的值。
代碼:
int sum = 0;
int res = -INF;
for (int i = 0; i < N; i++) {
sum += nums[i];
res = Math.max(res, sum);
if (sum < 0)
sum = 0;
}
有長度限制的子序和
加入長度限制,這里有兩種條件。一種是長度不大於m,另一種是長度不小於m。
無論哪種情況,都可以預處理出前綴和,這樣可以在\(O(1)\)時間求出某一子段的和。
長度不大於m
相當於一個不大於m的滑動窗口,每次有元素sum[i]將要入隊時,求子序列以其作為結尾,則子序列頭需要盡可能的小。這個最小值是從大小為m的窗口中選取的,這個問題可以用單調隊列維護一個滑動窗口的最小值來實現。
代碼:
int head = 0, tail = -1;
int res = 0xcfcfcfcf;
for (int i = 1; i <= n; i++) {
if (i - q[head] > m)
head++;
res = Math.max(res, s[i] - s[q[head]]);
while (head <= tail && s[i] <= s[q[tail]]) {
tail--;
}
q[++tail] = i;
}
長度不小於m
方法一
枚舉結束點,則起始點必須滿足\(j + m - 1 <= i\),則等價於求下式的值:
代碼:
for (int i = m; i <= n; i++) {
int min = INF;
for (int j = 0; j + m - 1 <= i; j++)
min = Math.min(min, s[j]);
res = Math.max(res, s[i] - min);
}
算法復雜度: \(O(n ^ 2)\)
方法二
方法一中,為了求滿足條件的起始點,需要從頭開始的掃描,但每當i的指針加1時,\(sum_j\)只多了一個,因此可以動態維護滿足起始點的最小值。
代碼:
int min = INF;
for (int i = m; i <= n; i++) {
min = Math.min(min, s[i - m]);
res = Math.max(res, s[i] - min);
}
不重復子序列
問題描述:
求給定序列不重復子序列的最大長度,即子序列中每個元素只出現一次
分析
當序列中新增添一個元素a時,如果不滿足不重復性,則存在和a相同的元素,如果想要子序列以a結尾,則至少起始指針從與a相同元素的后一個開始。
方法
雙指針算法,記錄兩個指針位置\(i,j\)分別表示結束和開始下標。當遇到重復元素時,移動\(j\)直到\(i\)到\(j\)的窗口內不含有重復元素。
算法復雜度:\(O(n)\)
代碼:
int res = 1;
for (int i = 0, j = 0; i < n; i++) {
count[nums[i]]++;
while (count[nums[i]] > 1) {
count[nums[j]]--;
j++;
}
res = Math.max(res, i - j + 1);
}
這個算法的起始指針是步進的方式,還可以用一個map來存儲元素和下標,這樣在遇到重復元素時,可以直接跳到合法的最小位置。
上升子序列問題
原問題
問題描述:
求一個給定序列的嚴格上升子序列的長度
方法:
動態規划
用\(dp[i]\)表示以下標\(i\)結尾的子序列的最大長度,顯然狀態轉移需要從下標小於\(i\)且序列結尾小於\(a[i]\)的那些序列中選擇。
代碼:
for (int i = 1; i < n; i++)
for (int j = 0; j < i; j++)
if (a[i] > a[j])
dp[i] = Math.max(dp[i], dp[j] + 1);
想要求出最大長度只需枚舉結尾下標即可,當然也可以在更新dp的同時,也同時維護一個最大長度。
算法復雜度: \(O(n^2)\)
單調隊列優化
在前一個問題中發現能否狀態轉移之和前序列的尾有關,因此可以用一個數組\(last [\ ]\)來存儲以每個長度下,最小需要多大才能對接到前子序列。
長度更長的序列是由較短序列構成的因此有: \(last[i] < last[j] \quad when \ i < j\)
\(last\)數組現在成為了單調隊列,在新加一個數\(a[ i]\)時,該數可以在\(last\)數組選擇一個比它大的第一個數,在長度不變下,如果以\(a[i ]\)更新,結尾變小,更容易被加長。在選擇合適的放置位置時,可以用二分。
代碼:
queue[0] = 0xcfcfcfcf;
int len = 0;
for (int i = 0; i < n; i++) {
int l = 0;
int r = len;
while (l < r) {
int mid = (l + r + 1) >> 1;
if (queue[mid] < a[i])
l = mid;
else
r = mid - 1;
}
len = Math.max(len, r + 1);
queue[r + 1] = a[i];
}
注意這里下標的具體含義,選擇合適的二分方式,\(queue[0 ]\)是設置的一個虛擬頭節點。
**算法復雜度: **\(O(n\log n)\)
最大上升子序和
\(dp[i]\)表示以\(i\)結尾上升子序列的最大和,狀態轉移也是顯然的。
代碼:
for (int i = 0; i < N; i++) {
dp[i] = nums[i];
for (int j = 0; j < i; j++) {
if (nums[i] > nums[j]) {
dp[i] = Math.max(dp[i], dp[j] + nums[i]);
}
}
}
長度越長不代表子序和越大,所以無法用單調隊列優化。
公共子序列問題
最長公共子序列
問題描述:
求字符串a,b的最長公共子序列的長度
方法:
動態規划,\(dp[i][j]\)表示\(a\)的前\(i\)個字符,\(b\)的前\(j\)個字符中的最大公共子序列長度。
狀態轉移方程:
當a[i] 和b[j]相同時,多了一條轉移路線。
代碼:
for (int i = 0; i < N; i++) {
for (int j = 0; j < M; j++) {
dp[i + 1][j + 1] = Math.max(dp[i][j + 1], dp[i + 1][j]);
if (a.charAt(i) == b.charAt(j))
dp[i + 1][j + 1] = Math.max(dp[i + 1][j + 1], dp[i][j] + 1);
}
}
最長公共上升子序列
與上一問題相比增加了上升的約束。
解題方式依舊是動態規划,這次\(dp[i][j]\)表示的是\(A\)的前\(i\)個和\(B\)的以\(j\)結尾的公共上升子序列長度最大值。
這里引入了不對稱的表示,第一維是前i個,第二維是以j結尾,后者約束更強。
代碼:
for (int i = 1; i <= N; i++) {
for (int j = 1; j <=N; j++) {
dp[i][j] = dp[i - 1][j];
if (A[i] == B[j]) {
dp[i][j] = Math.max(1, dp[i][j]);
for (int k = 1; k < j; k++) {
if (B[k] < B[j])
dp[i][j] = Math.max(dp[i][j], dp[i - 1][k] + 1);
}
}
}
}
總結
通過上面幾個子序列模型,學習了相應的解決辦法。往往只是增加了一個約束條件后,問題的求解方式完全不同。
我們用了動態規划,雙指針,單調隊列優化,希望仔細體會不同問題細微差別,在遇到新的問題學會知識遷移。
