1. 程式人生 > >聽說下雨天,子序列和孤單的你更配哦~

聽說下雨天,子序列和孤單的你更配哦~

記憶 fff 註釋 ati -o 原因 方程 pos 每一個

一、\(DP\)的意義以及線性動規簡介

動態規劃自古以來是\(DALAO\)淩虐萌新的分水嶺,但有些OIer認為並沒有這麽重要——會打暴力,大不了記憶化。但是其實,動態規劃學得好不好,可以彰顯出一個\(OIer\)的基本素養——能否富有邏輯地思考一些問題,以及更重要的——能否將數學、算籌學(決策學)、數據結構合並成一個整體並且將其合理運用\(qwq\)

而我們首先要了解的,便是綜合難度在所有動規題裏最為簡單的線性動規了。線性動規既是一切動規的基礎,同時也可以廣泛解決生活中的各項問題——比如在我們所在的三維世界裏,四維的時間就是不可逆式線性,比如我們需要決策在相同的時間內做價值盡量大的事情,該如何決策,最優解是什麽——這就引出了動態規劃的真正含義:

在一個困難的嵌套決策鏈中,決策出最優解。

二、動態規劃性質淺談

首先,動態規劃和遞推有些相似(尤其是線性動規),但是不同於遞推的是:

遞推求出的是數據,所以只是針對數據進行操作;而動態規劃求出的是最優狀態,所以必然也是針對狀態的操作,而狀態自然可以出現在最優解中,也可以不出現——這便是決策的特性(布爾性)。

其次,由於每個狀態均可以由之前的狀態演變形成,所以動態規劃有可推導性,但同時,動態規劃也有無後效性,即每個當前狀態會且僅會決策出下一狀態,而不直接對未來的所有狀態負責,可以淺顯的理解為——

_ \(\mathcal{Future \ \ never \ \ has \ \ to \ \ do \ \ with \ \ past \ \ time \ \ ,but \ \ present \ \ }.\)
_

現在決定未來,未來與過去無關。

三、扯正題——子序列問題

(一)一個序列中的最長上升子序列(\(LIS\)

例:由6個數,分別是: 1 7 6 2 3 4,求最長上升子序列。

評析:首先,我們要理解什麽叫做最長上升子序列:1、最長上升子序列的元素不一定相鄰 2、最長上升子序列一定是原序列的子集。所以這個例子中的\(LIS\)就是:1 2 3 4,共4個

1、\(n^2\)做法

首先我們要知道,對於每一個元素來說,最長上升子序列就是其本身。那我們便可以維護一個\(dp\)數組,使得\(dp[i]\)表示以第\(i\)元素為結尾的最長上升子序列長度,那麽對於每一個\(dp[i]\)而言,初始值即為\(1\)

那麽dp數組怎麽求呢?我們可以對於每一個\(i\),枚舉在\(i\)之前的每一個元素\(j\),然後對於每一個\(dp[j]\),如果元素\(i\)大於元素\(j\),那麽就可以考慮繼承,而最優解的得出則是依靠對於每一個繼承而來的\(dp\)值,取\(max\).

    for(int i=1;i<=n;i++)
    {
        dp[i]=1;//初始化 
        for(int j=1;j<i;j++)//枚舉i之前的每一個j 
        if(data[j]<data[i] && dp[i]<dp[j]+1)
        //用if判斷是否可以拼湊成上升子序列,
        //並且判斷當前狀態是否優於之前枚舉
        //過的所有狀態,如果是,則↓ 
        dp[i]=dp[j]+1;//更新最優狀態 
        
    }

最後,因為我們對於\(dp\)數組的定義是到i為止的最長上升子序列長度,所以我們最後對於整個序列,只需要輸出\(dp[n]\)(\(n\)為元素個數)即可。

從這個題我們也不難看出,狀態轉移方程可以如此定義:

下一狀態最優值=最優比較函數(已經記錄的最優值,可以由先前狀態得出的最優值)

——即動態規劃具有 判斷性繼承思想

2、\(nlogn\) 做法

我們其實不難看出,對於\(n^2\)做法而言,其實就是暴力枚舉:將每個狀態都分別比較一遍。但其實有些沒有必要的狀態的枚舉,導致浪費許多時間,當元素個數到了\(10^4-10^5\)以上時,就已經超時了。而此時,我們可以通過另一種動態規劃的方式來降低時間復雜度:

將原來的dp數組的存儲由數值換成該序列中,上升子序列長度為i的上升子序列,的最小末尾數值

這其實就是一種幾近貪心的思想:我們當前的上升子序列長度如果已經確定,那麽如果這種長度的子序列的結尾元素越小,後面的元素就可以更方便地加入到這條我們臆測的、可作為結果、的上升子序列中。

qwq一定要好好看註釋啊!

int n;
    cin>>n;
    for(int i=1;i<=n;i++)
    {
        cin>>a[i];
        f[i]=0x7fffffff;
        //初始值要設為INF
        /*原因很簡單,每遇到一個新的元素時,就跟已經記錄的f數組當前所記錄的最長
        上升子序列的末尾元素相比較:如果小於此元素,那麽就不斷向前找,直到找到
        一個剛好比它大的元素,替換;反之如果大於,麽填到末尾元素的下一個q,INF
                就是為了方便向後替換啊!*/ 
    }
    f[1]=a[1];
    int len=1;//通過記錄f數組的有效位數,求得個數 
    /*因為上文中所提到我們有可能要不斷向前尋找,
    所以可以采用二分查找的策略,這便是將時間復雜
    度降成nlogn級別的關鍵因素。*/ 
    for(int i=2;i<=n;i++)
    {
        int l=0,r=len,mid;
        if(a[i]>f[len])f[++len]=a[i];
        //如果剛好大於末尾,暫時向後順次填充 
        else 
        {
        while(l<r)
        {   
            mid=(l+r)/2;
            if(f[mid]>a[i])r=mid;
    //如果仍然小於之前所記錄的最小末尾,那麽不斷
    //向前尋找(因為是最長上升子序列,所以f數組必
    //然滿足單調) 
            else l=mid+1; 
        }
        f[l]=min(a[i],f[l]);//更新最小末尾 
        }
    }
    cout<<len;

\(Another \ \ Situation\)

但是事實上,\(nlogn\)做法偷了個懶,沒有記錄以每一個元素結尾的最長上升子序列長度。那麽我們對於\(n^2\)的統計方案數,有很好想的如下代碼(再對第一次的\(dp\)數組\(dp\)一次):

for(i = 1; i <= N; i ++){
    if(dp[i] == 1) f[i] = 1 ;
    for(j = 1; j <= N: j ++)
        if(base[i] > base[j] && dp[j] == dp[i] - 1) f[i] += f[j] ;
        else if(base[i] == base[j] && dp[j] == dp[i]) f[i] = 0 ;
    if(f[i] == ans) res ++ ;
    }

但是\(nlogn\)呢?雖然好像也可以做,但是想的話會比較麻煩,在這裏就暫時不討論了\(qwq\),但筆者說這件事的目的是為了再次論證一個觀點:時間復雜度越高的算法越全能


\(3\)、輸出路徑

只要記錄前驅,然後遞歸輸出即可(也可以用棧的)

下面貼出\(n ^ 2\)的完整代碼qwq

#include <iostream>
using namespace std;
const int MAXN = 1000 + 10;
int n, data[MAXN];
int dp[MAXN]; 
int from[MAXN]; 
void output(int x)
{
    if(!x)return;
    output(from[x]);
    cout<<data[x]<<" ";
    //叠代輸出 
}
int main()
{
    cin>>n;
    for(int i=1;i<=n;i++)cin>>data[i];
    
    // DP
    for(int i=1;i<=n;i++)
    {
        dp[i]=1;
        from[i]=0;
        for(int j=1;j<i;j++)
        if(data[j]<data[i] && dp[i]<dp[j]+1)
        {
            dp[i]=dp[j]+1;
            from[i]=j;//逐個記錄前驅 
        }
    }
    
    int ans=dp[1], pos=1;
    for(int i=1;i<=n;i++)
        if(ans<dp[i])
        {
            ans=dp[i];
            pos=i;//由於需要遞歸輸出
    //所以要記錄最長上升子序列的最後一
    //個元素,來不斷回溯出路徑來 
        }
    cout<<ans<<endl;
    output(pos);
    
    return 0;
}

(二)兩個序列中的最長公共子序列(\(LCS\)

1、譬如給定2個序列:

1 2 3 4 5

3 2 1 4 5

試求出最長的公共子序列。

\(qwq\)顯然長度是\(3\),包含\(3 \ \ 4 \ \ 5\) 三個元素(不唯一)

解析:我們可以用\(dp[i][j]\)來表示第一個串的前\(i\)位,第二個串的前j位的\(LCS\)的長度,那麽我們是很容易想到狀態轉移方程的:

如果當前的\(A1[i]\)\(A2[j]\)相同(即是有新的公共元素)
那麽

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

如果不相同,即無法更新公共元素,考慮繼承:

$dp[ i ] [ j ] = max(dp[ i-1 ][ j ] , dp[ i ][ j-1 ] $

那麽代碼:

#include<iostream>
using namespace std;
int dp[1001][1001],a1[2001],a2[2001],n,m;
int main()
{
   //dp[i][j]表示兩個串從頭開始,直到第一個串的第i位 
   //和第二個串的第j位最多有多少個公共子元素 
   cin>>n>>m;
   for(int i=1;i<=n;i++)scanf("%d",&a1[i]);
   for(int i=1;i<=m;i++)scanf("%d",&a2[i]);
   for(int i=1;i<=n;i++)
    for(int j=1;j<=m;j++)
     {
       dp[i][j]=max(dp[i-1][j],dp[i][j-1]);
       if(a1[i]==a2[j])
       dp[i][j]=max(dp[i][j],dp[i-1][j-1]+1);
       //因為更新,所以++; 
     }
   cout<<dp[n][m];
}

\(2\)、而對於洛谷\(P1439\)而言,不僅是卡上面的樸素算法,也考察到了全排列的性質:

對於這個題而言,樸素算法是\(n^2\)的,會被\(10^5\)卡死,所以我們可以考慮\(nlogn\)的做法:

因為兩個序列都是\(1~n\)的全排列,那麽兩個序列元素互異且相同,也就是說只是位置不同罷了,那麽我們通過一個\(map\)數組將\(A\)序列的數字在\(B\)序列中的位置表示出來——

因為最長公共子序列是按位向後比對的,所以a序列每個元素在b序列中的位置如果遞增,就說明b中的這個數在a中的這個數整體位置偏後,可以考慮納入\(LCS\)——那麽就可以轉變成\(nlogn\)求用來記錄新的位置的map數組中的\(LIS\)

最後貼\(AC\)代碼:

#include<iostream>
#include<cstdio>
using namespace std;
int a[100001],b[100001],map[100001],f[100001];
int main()
{
    int n;
    cin>>n;
    for(int i=1;i<=n;i++){scanf("%d",&a[i]);map[a[i]]=i;}
    for(int i=1;i<=n;i++){scanf("%d",&b[i]);f[i]=0x7fffffff;}
    int len=0;
    f[0]=0;
    for(int i=1;i<=n;i++)
    {
        int l=0,r=len,mid;
        if(map[b[i]]>f[len])f[++len]=map[b[i]];
        else 
        {
        while(l<r)
        {   
            mid=(l+r)/2;
            if(f[mid]>map[b[i]])r=mid;
            else l=mid+1; 
        }
        f[l]=min(map[b[i]],f[l]);
        }
    }
    cout<<len;
    return 0
}

_ \(\mathcal{Although \ \ there‘re \ \ difficulties \ \ ahead \ \ of \ \ us \ \ , \ \ remember \ \ :}\) _

就算出走半生,歸來仍要是少年

聽說下雨天,子序列和孤單的你更配哦~