子序列問題及其算法


子序列問題

前言

筆者曾經參加頭條的面試,面試官在算法環節問的就是這個問題,首先問了我連續子序列的情形,之后改為一般子序列。一般面試算法都是這種循序漸進的方式,先給出一個簡單情形,接着將問題提升,並不斷優化。題目不一定很難,主要考核面試者的應變和分析能力。可惜當時對算法理解還是過於淺顯,作為總結寫下這篇文章。

連續子列問題

最大子序和

問題描述:

求給定序列的所有連續子序列的最大和

分析

如果一個連續的子序列它的頭部和負(即以起點開始的連續子列),則將頭部丟去后得到的序列和更大。因此,每當有頭部和負時,置總和為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\),則等價於求下式的值:

\[\max_{m<=i <n}\{sum_i - \min_{j + m - 1 <= i}(sum_j)\} \]

代碼:

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\)個字符中的最大公共子序列長度。

狀態轉移方程

\[dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]) \]

當a[i] 和b[j]相同時,多了一條轉移路線。

\[dp[i][j] = max(dp[i][j], dp[i - 1][j - 1] + 1)\quad if \ 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);
            }
        }
    }
}

總結

通過上面幾個子序列模型,學習了相應的解決辦法。往往只是增加了一個約束條件后,問題的求解方式完全不同。

我們用了動態規划,雙指針,單調隊列優化,希望仔細體會不同問題細微差別,在遇到新的問題學會知識遷移。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM