1. 程式人生 > >hihor學習日記:hiho一下 第六十週

hihor學習日記:hiho一下 第六十週

http://www.hihocoder.com/contest/hiho60/problem/1

主要內容來源於:http://www.hihocoder.com/discuss/question/2111

題意分析

給定只包含字母的兩個字串A,B,求A,B兩個字串的最長公共子序列,要求構成子序列的子串長度都必須大於等於3。

比如"abcdefghijklmn"和"ababceghjklmn",其最長滿足題意要求的子序列為"abcjklmn",其由公共子串"abc"和"jklmn"組成。

這裡我們要注意子串和子序列的區別:

子串:連續的元素
子序列:不連續的元素
比如"abcdefghijklmn"和"ababceghjklmn"的最長公共子串就只是"jklmn"了。

演算法分析

首先我們來複習一道經典的題目:

給定只包含字母的兩個字串A,B,求A,B兩個字串的最長公共子序列。

比如"abcde"和"abdfg"的最長公共子序列為"abd"

對於最長公共子序列,我們知道解法為


dp[0][0..j] = 0    // 邊界
dp[0..i][0] = 0    // 邊界
For i = 1 .. n
    For j = 1 .. m
        If a[i] == b[j] Then
            dp[i][j] = dp[i - 1][j - 1] + 1
        Else
            dp[i][j]
= Max(dp[i - 1][j], dp[i][j - 1]) End If End For End For

而這一道題目是在最長公共子序列上加入了一個條件:構成最長公共子序列的每一個子串長度必須大於等於3.

一個簡單的想法:我們求出最長公共子序列,然後將其中長度小於3的部分去掉。

顯然,這是不對的。

舉個例子:“aaabaa"和"acaaaca"的最長子序列為"aaaaa”。其對應關係為:

a aaba a
acaa aca
因為在"acaaaca"中第一個字母a長度為1,所以我們需要去掉它,對應的我們也去掉了"aaabaa"中第一個字母a。

. aaba a
. caa aca
此時構成"aaabaa"和"acaaaca"公共子序列的3個子串為"aa",“a"和"a”,長度都小於了3,所以全部刪去,則得到了新的公共子序列長度為0。

這顯然不正確,因為實際有符合題意要求的公共子序列:

aaa baa
ac aaa ca
其中包含有長度為3的公共子序列。

對最大公共子序列的結果進行再次處理這個方法不可行,那麼我們只能從計算公共子序列的演算法著手。

首先我想我們可以做一個預處理,用f[i][j]表示以a的第i個字母作為結尾的字首和以b的第j個字母作為結尾的字首的公共字尾的長度。這樣看上去似乎很繞,不如舉個例子:

a=“abcd"和b=“acbc”。f[3][4]的就表示a[1…3]和b[1…4]的公共字尾的長度,其中a[1…3]=“abc”,b[1…4]=“acbc”,其公共字尾為"bc”,所以f[3][4]=2.

預處理的虛擬碼為:

For i = 1 .. n
    For j = 1 .. m
        If a[i] == b[j] Then
            f[i][j] = f[i - 1][j - 1] + 1
        Else
            f[i][j] = 0
        End If
    End For
End For

有了這個預處理的陣列,我們可以在原來最大公共子序列上做這樣一個改進:


dp[0][0..j] = 0    // 邊界
dp[0..i][0] = 0    // 邊界
For i = 1 .. n
    For j = 1 .. m
        If f[i][j] >= 3 Then    // 改進
            dp[i][j] = dp[i - f[i][j]][j - f[i][j]] + f[i][j]
        Else
            dp[i][j] = Max(dp[i - 1][j], dp[i][j - 1])
        End If
    End For
End For

這個改進的意義為:當我們出現一個長度大於3的子串時,我們就直接將這個子串合併入我們的子序列。

加入這個改進後,我們通過了樣例的資料,這樣看上去似乎就應該沒什麼問題了。

然而事實並不是這樣,在這道題目中還隱藏著陷阱:

比如"abcdef"和"abcxcdef"

根據我們演算法,上面這個例子算出的結果為4,然而其實際的結果應該為6,即"abc"和"def"兩個公共子串構成的子序列。

那麼出錯的原因在哪?就在字串"cdef"上。

我們計算結果出4是因為將"cdef"看做了一個整體,而將"abcdef"分割成了"ab"和"cdef"。

在DP的過程中f[6][7] = 4,我們使用了dp[6][7] = dp[2][3] + 4,而dp[2][3] = 0,所以dp[6][7] = 4。

ab cdef
abcxcdef
而實際上的最有解是將f[6][7]看作3,dp[6][7] = dp[3][4] + 3,其中dp[3][4] = 3,得到了dp[6][7] = 6。

abc def
abcxcdef
也就是說,如果我們將f[i][j]>3的子串進行分割,有可能得到更優的情況。因此我們需要進一步的改進:


dp[0][0..j] = 0    // 邊界
dp[0..i][0] = 0    // 邊界
For i = 1 .. n
    For j = 1 .. m
        dp[i][j] = 0
        If f[i][j] >= 3 Then    // 改進
            For k = 3 .. f[i][j]    // 列舉分割長度
                dp[i][j] = Max(dp[i][j], dp[i - k][j - k] + k)
            End For
        End If
        dp[i][j] = Max(dp[i - 1][j], dp[i][j - 1])
    End For
End For

但是這樣的改進使得整個演算法的時間複雜度變為了O(n^3),當n=2100時,有可能會超時。

讓我們考慮一下如何進一步改進這個演算法。以上演算法複雜度高的地方在於對於每一個(i, j),我們為了計算dp[i][j]都需要列舉分割長度k:

For k = 3 … f[i][j] // 列舉分割長度
dp[i][j] = Max(dp[i][j], dp[i - k][j - k] + k)
End For
這一步實際上我們計算了max{dp[i-k][j-k]+k}, k=3…f[i][j]。我們不妨把它記作dp1[i][j],即:

dp1[i][j] = max{dp[i-k][j-k]+k} = max{dp[i-3][j-3]+3, dp[i-4][j-4]+4, dp[i-5][j-5]+5, ... }
同時

dp1[i-1][j-1] = max{dp[i-1-3][j-1-3]+3, dp[i-1-4][j-1-4] + 4, dp[i-1-5][j-1-5]+5 ... }
    = max{dp[i-4][j-4]+3, dp[i-5][j-5]+4, dp[i-6][j-6]+5, ... }

我們可以發現,dp1[i][j]的展開式中除了dp[i-3][j-3]+3這一項,是與dp1[i-1][j-1]中的每一項一一對應的,並且剛好大1。所以實際上dp[i-1][j-1]計算時列舉過分割長度,我們並不需要再次計算:

dp1[i][j] = max{dp1[i-1][j-1] + 1, dp[i-3][j-3]+3}
最後得到我們新的虛擬碼如下,其中dp[i][j][0]對應上文分析中的dp[i][j], dp[i][j][1]對應dp1[i][j]:

dp[0][0..j][0..1] = 0    // 邊界
dp[0..i][0][0..1] = 0    // 邊界
For i = 1 .. n
    For j = 1 .. m
        dp[i][j][1] = 0
        If f[i][j] >= 3 Then    // 改進
            dp[i][j][1] = Max(dp[i][j][1], dp[i - 3][j - 3][0] + 3)     // 以長度3為分割
            If (f[i][j] > 3) Then
                //按照dp[i-1][j-1][1]的分割方式分割,即直接將(i,j)接在(i-1,j-1)後面
                dp[i][j][1] = Max(dp[i][j][1], dp[i - 1][j - 1][1] + 1)
            End If
        End If
        dp[i][j][0] = Max(dp[i-1][j][0], dp[i][j-1][0], dp[i][j][1])
    End For
End For

至此我這道題目也算是完整的解出了。

結果分析
這個題目是在經典的動態規劃題目《最長公共子序列》上做了一點修改。雖然只增加了一個條件,不過難度增大很多。能想出一個複雜度是O(n^2)的正確演算法不是很容易,需要仔細分析清楚各種情況。一不小心就會掉進各種陷阱裡。

很多選手都能夠想到經典最長子序列的改進演算法而獲得80分。

剩下的測試點則對應了演算法分析中提到的陷阱,所以能否找出這種特殊的例子也是解決這道題的關鍵。

不過微軟的出題人似乎沒有想太為難大家,資料並不是很強。在實際的比賽中,O(n^3)的演算法也能拿到滿分,最終該題目的通過率為9%。

很多O(N^2)的程式不能通過"babad"和"babacabad"這組資料。

dp1是代表的

For k = 3 .. f[i][j]    // 列舉分割長度
                dp[i][j] = Max(dp[i][j], dp[i - k][j - k] + k)

但是這麼裸著寫的話,複雜度太高,就用dp的方法代替

AC程式碼:

#include <bits/stdc++.h>

using namespace std;
#define LL long long
const int Mod = 1e9 + 7;
const int maxn = 2200;
const double eps = 0.00000001;
const int INF = 0x3f3f3f3f;

int dp[maxn][maxn][2], f[maxn][maxn];

int main()
{
    string aa, bb;
    cin >> aa >> bb;
    memset(dp, 0, sizeof(dp));
    memset(f, 0, sizeof(f));
    for (int i = 1; i <= aa.size(); i ++) {
        for (int j = 1; j <= bb.size(); j ++) {
            if(aa[i - 1] == bb[j - 1]) f[i][j] = f[i - 1][j - 1] + 1;
            else f[i][j] = 0;
        }
    }
    for (int i = 1; i <= aa.size(); i ++) {
        for (int j = 1; j <= bb.size(); j ++) {
            if(f[i][j] >= 3) {
                dp[i][j][1] = max(dp[i][j][1], dp[i - 3][j - 3][0] + 3);
                if(f[i][j] > 3) dp[i][j][1] = max(dp[i][j][1], dp[i - 1][j - 1][1] + 1);
            }
            dp[i][j][0] = max(max(dp[i - 1][j][0], dp[i][j - 1][0]), dp[i][j][1]);
        }
    }
    cout << dp[aa.size()][bb.size()][0] << endl;
    return 0;
}