淺析LIS & LCS

\(LIS\)(最長上升子序列)

求長度

\(dp\) - \(O(n ^ 2)\)

動態規劃的做法

\(f[i]\)表示以第\(i\)個元素結尾的\(LIS\)長度

則有: \(f[i] = max(f[i],f[j] + 1),(a[j] < a[i],j < i)\)

通過枚舉\(f[i]\)\(f[j]\)來不斷轉移狀態,然後不斷枚舉更新最大值

\(Code\):

#include<cstdio>
using namespace std;
#define maxn 10005
int f[maxn],a[maxn];
int main()
{
    int n,Max = 0;
    scanf("%d",&n);
    for(int i = 1;i <= n;i ++) scanf("%d",&a[i]);
    for(int i = 1;i <= n;i ++)
    {
        f[i]  = 1;
        for(int j = 1;j < i;j ++) 
            if(a[j] < a[i] && f[i] < f[j] + 1) f[i] = f[j] + 1;
    }
    for(int i = 1;i <= n;i ++) if(f[i] > Max) Max = f[i];
    printf("%d",Max);
    return 0;
}

該算法可以求出具體的最長上升子序列,但是在只要求最長上升子序列長度時,我們通常可以考慮更優的\(O(n \ log_2 \ n)\)的做法

\(dp\)+樹狀數組\(O(n \ log_2 \ n)\)

注意到我們在狀態轉移的時候要枚舉\(f[j]\)的最大值來轉移,我們可以考慮使用數據結構來維護從而優化一下,只要是支持單點修改和區間最值查詢的數據結構都可以這麼做,分塊\((O(n \sqrt n))\)和樹狀數組\((O(n \ log_2 \ n))\),線段樹\((O(n \ log_2 \ n))\)之類的都行,但是因爲樹狀數組比較好寫,所以我們只講解樹狀數組的寫法

  1. 先按權值排序,排序之後再查詢序號前最大的\(f[j]\)來轉移,但是有一點要注意,我們求的是LIS,是嚴格上升的,所以我們遇到重複的權值的時候應該要放在最後一次性處理,不然後面的重複了的\(f[]\)就能夠用前面相同的元素來轉移,導致最後的答案是錯誤的
#include <cstdio>
#include <algorithm>
using namespace std;
#define maxn 1000007
int n,Dp[maxn],Ans,Max[maxn];
struct Node{int w,i;}A[maxn];
#define lowbit(x) ((x) & (-x))
inline bool cmp(Node A , Node B){return A.w < B.w;}
inline void Update(int Pos , int w) 
{
    for(int i = Pos;i <= n; i += lowbit(i)) 
        Max[i] = max(Max[i] , w);
}
inline int Query(int Pos) 
{
    int Ret = 0;
    for(int i = Pos ; i ; i -= lowbit(i)) 
        Ret = max(Ret , Max[i]);
    return Ret;
}
int main() 
{
	scanf("%d" , &n);
	for(int i = 1;i <= n;i ++) scanf("%d" , &A[i].w) , A[i].i = i;
	sort(A + 1 , A + 1 + n ,cmp);
	int Last = 1;//爲了處理權值相同時的情況
	for(int i = 1;i <= n;i ++) //確保權值的大小關係正確
	{
		if(A[i].w != A[i - 1].w && i - 1) //處理前面權值相同的情況
		{
			for(int j = Last;j <= i - 1;j ++) Update(A[j].i , Dp[j]); 
			//如果不是到了最後再更新的話,後面重複的就會用前面重複的值來更新
			Last = i;//處理完轉移過來
		}
		Dp[i] = Query(A[i].i) + 1;//轉移
		Ans = max(Ans , Dp[i]);
	}
	printf("%d" , Ans);
	return 0;
}
  1. 維護\(f[]\)這個數組,但是用權值作爲數組下標,然後不需要\(sort\),順序枚舉就可以了,關於值的大小我們可以直接查找(樹狀數組),注意到範圍很大時,我們可以進行離散化
#include <cstdio>
#include <algorithm>
using namespace std;
#define maxn 1000007
int n,ans,f[maxn];
struct Node{int val,num;}z[maxn]; 
#define lowbit(x) ((x) & (-x))
inline void modify(int x,int y)
{
	for(;x < maxn ;x += lowbit(x))
		f[x] = max(f[x],y);
	return ;
}
inline int query(int x)
{
	int res = 0;
	for(;x;x -= lowbit(x)) res = max(res,f[x]);
	return res;
} 
int main()
{
	scanf("%d",&n);
	for(int i = 1;i <= n;i ++) scanf("%d",&z[i].val);
	for(int i = 1;i <= n;i ++)
	{
		int Max = query(z[i].val - 1);
		modify(z[i].val , ++ Max);
		ans = max(ans,Max);
	}
	printf("%d",ans);
	return 0;
}

貪心+二分\(O(n \ log_2 \ n)\)

貪心的做法

維護一個單調棧,然後根據棧定元素元素和當前元素作比較來選擇最優策略,定義\(stack[]\)爲單調棧,棧頂元素爲\(stack[top]\),當前序列的第\(i\)個元素爲\(a[i]\)

  1. 如果\(stack[top] < a[i]\) 那麼滿足單調,可以直接壓入棧中
  2. 如果\(stack[top] \geqslant a[i]\) 那麼這個時候插入就不滿足單調了,那麼我們考慮在單調棧中進行二分查找,然後找到第一個\(stack[j] \geqslant a[i]\)進行替換即可

Q:爲什麼進行替換這一貪心的策略是可行的?

A: 因爲這樣做並沒有增長棧的長度,而且這麼一接下去就可以有更好的方案,其實感性的理解就是一個在棧中不止一個序列,可以理解爲兩條或者更多,但是在替換之後的元素的壓入可以應用到每一條序列中作出貢獻,比如下面的這個例子

\(Input\):

5
1 4 2 5 3

稍稍根據上面的貪心決策不難推出這樣的一個過程:

  1. stack[1] = {1};
  2. stack[2] = {1,4};
  3. stack[2] = {1,2};
  4. stack[3] = {1,2,5};
  5. stack[3] = {1,2,3};

看到第\(3\)步中的替換過程,\(4\)變成了\(2\),其實\(4\)也存在{1,4,5}這樣的最長上升子序列,但是如果後面還有數字就沒有\(2\)更優,在我們替換過後,\(4\)其實也在參與,但是因爲我們發現的更加優越的\(2\),所以\(4\)的貢獻肯定比\(2\)的貢獻要小,可以直接替換掉

\(Warning\)

\(O(n \ log_2 \ n)\)算法其實不能求出具體的最長上升子序列,因爲中間在替換的過程中就已經把原有的順序給打亂了,對於替換掉的元素,不知道是否能夠對於後面的最長上升子序列作出價值,所以是不可以的

\(Code\):

#include<cstdio>
#include<iostream>
#include<algorithm>
using namespace std;
#define maxn 100005
int a[maxn],d[maxn];
int main() 
{
    int n,len = 1;
    scanf("%d",&n);
    for(int i = 1;i <= n;i ++) scanf("%d",&a[i]);
    d[1] = a[1];
    for(int i = 2; i <= n; i ++)
    {
        if(d[len] < a[i]) d[++ len] = a[i];
        else *lower_bound(d + 1 , d + 1 + len , a[i]) = a[i];
    }
    printf("%d",len);
    return 0;
}

求具體\(LIS\)序列

此題\(dp - O(n ^ 2)\)中的思想可以應用,相應的,我們可以再加上一個樹狀數組來優化算法的時間複雜度到\(O(n \ log_2 \ n)\),通過記錄一個結尾對應的序列中的前驅來優化算法的空間複雜度至\(O(n)\),然後就十分的可做了,因爲樹狀數組有兩種寫法,作者在此只寫出一種做法的解法,另外一種解法可以讓讀者自行思考,不過最好是離散化一下

#include <cstdio>
#include <algorithm>
using namespace std;
#define maxn 1000007
int n,Dp[maxn],Ans,Max[maxn],Max_num[maxn],pre[maxn],num = 0,a[maxn];
struct Node{int w,i;}A[maxn];
#define lowbit(x) ((x) & (-x))
inline bool cmp(Node A , Node B){return A.w < B.w;}
inline void Update(int Pos , int w,int Num) 
{
    for(int i = Pos;i <= n; i += lowbit(i)) 
        if(Max[i] < w) Max[i] = w,Max_num[i] = Pos;
    return ;
}
inline int Query(int Pos) 
{
    int Ret = 0;num = 0;
    for(int i = Pos ; i ; i -= lowbit(i)) 
        if(Ret < Max[i]) Ret = Max[i],num = Max_num[i];
    return Ret;
}
inline void Output(int first)
{
	if(!first) return ;
	else Output(pre[first]);
	printf("%d ",a[first]);
	return ;
}
int main() 
{
	scanf("%d" , &n);
	for(int i = 1;i <= n;i ++) scanf("%d",&a[i]),A[i].w = a[i],A[i].i = i;
	sort(A + 1 , A + 1 + n ,cmp);
	int Last = 1,first = 0;
	for(int i = 1;i <= n;i ++)
	{
		if(A[i].w != A[i - 1].w && i - 1)
		{
			for(int j = Last;j <= i - 1;j ++) Update(A[j].i , Dp[j],j); 
			Last = i;
		}
		Dp[i] = Query(A[i].i) + 1;
		pre[A[i].i] = num;
		if(Ans < Dp[i]) Ans = Dp[i],first = A[i].i;
	}
	printf("%d\n" , Ans);
	Output(first);
	return 0;
}

\(LCS\)(最長公共子序列)

求長度

\(O(nm)\)

動態規劃做法,其實原理很簡單,就是看當前的位上是否匹配的問題,然後根據這個來轉移狀態

設有長度爲\(n\)的串\(S\)與長度爲\(m\)的串\(T\),用\(f[i,j]\)表示\(S\)串前\(i\)個字符與\(T\)串前\(j\)個字符的\(LCS\)則有:

\(f[i,j] = max(f[i - 1,j],f[i,j - 1],(f[i - 1,j - 1] + 1) * [S_i = T_j])\)

也就是說,無論如何,一定有

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

討論特殊情況:當\(S_i = T_j\)

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

所以我們可以從這推出結果,然後\(f[n,m]\)就是最後的答案

因爲\(f[i]\)總是會從\(f[i - 1]\)這一維度轉移過來,所以我們可以考慮用滾動數組來優化空間複雜度

\(Code\):

#include<cstdio>
#include<cstring>
#include<iostream>
using namespace std;

const int maxn = 5e3 + 7;

int n,m,tmp;
char a[maxn],b[maxn];
int f[2][maxn];

int main()
{
    scanf("%s",a + 1);
    n = strlen(a + 1);
    scanf("%s",b + 1);
    m = strlen(b + 1);
    int now = 0;
    for(int i = 0;i <= n;i ++)
    {
        now ^= 1;
        for(int j = 1;j <= m;j ++)
        {
            f[now][j] = max(f[now ^ 1][j],f[now][j - 1]);
            tmp = f[now ^ 1][j - 1] + 1;
            if(a[i] == b[j] && f[now][j] < tmp) f[now][j] = tmp;
        }
    }
    printf("%d",f[now][m]);
    return 0;
}

\(O(n \ log_2 \ n)\)

其實我先講\(LIS\)是有原因的,我們可以嘗試着去探求這兩個問題之間的聯繫

如果我可以通過把這個原來的序列轉化爲另外一個序列然後求\(LIS\)來得到\(LCS\)就好了,那麼怎麼去轉換這兩個問題呢?

我們不難發現,\(LIS\)是用來求最長上升子序列的,所以當一個序列是升序排序,另外一個序列亂序時,求兩個序列的\(LCS\)其實就是在求亂序序列的\(LIS\),因爲有一個序列是升序的,所以一定存在亂序序列的最長上升子序列與升序序列的序列有最長公共子序列,所以就可以求出來了

所以我們在一開始輸入\(A\)序列的時候就將它序列中的元素逐一編號,然後再在B序列給對應的元素編上在\(A\)序列中的編號,也就是\(B\)序列中元素在\(A\)序列中的位置

比如說

1 5 4 3 2
5 3 1 2 4

編號之後就是

1 2 3 4 5
2 4 1 5 3

然後去求改變後的\(B\)序列的\(LIS\)就好了,也就是答案2 4 5,長度爲\(3\)

\(Warning\)
  1. 在兩個序列有不同元素的時候,處理的時候要去掉不同的元素,否則\(LIS\)可能會包含另外一個序列沒有的元素而導致答案錯誤

  2. 在兩個序列中有重複的元素時,不能用該方法處理

比如說:

abacc
cbabb

我們可以發現這兩個序列的\(LCS\)abba長度爲2

但是在處理賦值的過程中有點小麻煩

abacc
cbabb
1 2 3 4 5
5 2 3 2 2

我們發現在處理完的序列中有重複的數字,這是因爲在第一次給\(A\)中的元素賦值的時候,我們對於重複的元素賦了兩次值,於是就導致了序列中數字所對應的位置有多個,在處理過程中會覆蓋掉

那你可能會說:我不覆蓋掉不就是了

然後你這個\(naive\)的想法可能就會泡湯,因爲在某些情況下,不管你怎麼賦值,其實都不一定會是一個升序的序列,這個時候求另外一個序列的\(LIS\)就沒有意義了,因爲你本身的序列就不是升序,我求一遍升序就與另一個序列無關了

  1. 該算法不能求出具體的\(LCS\)序列,所以如果題目要求出具體的子序列時不能使用該算法,原因同\(LIS\)\(n \ log_2 \ n\)做法

\(Code\):

#include<cstdio>
#include<iostream>
#include<algorithm>
using namespace std;
#define maxn 100005
int a[maxn],b[maxn];
int d[maxn],stack[maxn],len = 0;
int main()
{
    int n;
    scanf("%d",&n);
    for(int i = 0;i < n;i ++) 
    {
        scanf("%d",&a[i]);
        d[a[i]] = i + 1;
    }
    for(int i = 0;i < n;i ++) 
    {
        scanf("%d",&b[i]);
        b[i] = d[b[i]];
    }
    stack[1] = b[0],len = 1;
    for(int i = 0;i < n;i ++)
    {
        if(stack[len] < b[i]) stack[++ len] = b[i];
        else *upper_bound(stack + 1,stack + 1 + len,b[i]) = b[i];
    }
    printf("%d",len);
    return 0;
}

求具體\(LCS\)序列

我們可以考慮借鑑一下\(LIS\)的寫法,記錄前驅和最大值開頭的數值,然後進行遞歸倒序輸出,但是這個時候我們的\(dp[]\)不能用數據結構來優化,所以我們的算法是\(O(nm)\)的,具體代碼請讀者自行思考

\(LCIS\)(最長公共上升子序列)

我們可以結合上面的\(LIS\)\(LCS\)的思想來思考這個問題

\(LIS:\)

\(f[i]\)表示以第\(i\)個元素結尾的\(LIS\)長度

則有: \(f[i] = max(f[i],f[j] + 1),(a[j] < a[i],j < i)\)

\(LCS:\)

設有長度爲\(n\)的串\(S\)與長度爲\(m\)的串\(T\),用\(f[i,j]\)表示\(S\)串前\(i\)個字符與\(T\)串前\(j\)個字符的\(LCS\)則有:

\(f[i,j] = max(f[i - 1,j],f[i,j - 1],(f[i - 1,j - 1] + 1) * [S_i = T_j])\)

稍加組合思考我們可以發現:

\(f[i,j]\)表示\(A\)序列前\(i\)個元素和\(B\)序列前\(j\)個元素的\(LCIS\)\(t\)表示\(LCIS\)的結尾元素位置,則有:

\(f[i,j] = f[i - 1,j],A_i \ne B_j\)

\(f[i,j] = max(f[i - 1,j],f[i - 1,t] + 1),A_i = B_j\)

又發現\(f[i]\)這一維每次都是從\(f[i - 1]\)這一維轉移過來,所以我們可以用滾動數組優化一下得到:

\(f_i\)代表序列\(A\)\(i\)個元素與序列\(B\)\(LCIS\)\(t\)爲結尾位置,有

\(f_j = f_t + 1,A_i = B_j\)

在計算\(LCIS\)的長度的過程中我們可以順便記錄前驅然後輸出具體的序列,所以就可以用\(O(nm)\)的時間複雜度算出\(LCIS\)長度與\(LCIS\)的具體序列

\(Code\):

#include<cstdio>
using namespace std;
#define maxn 505
int a[maxn],b[maxn],f[maxn],pos[maxn];
void output(int x)
{
    if(!x) return;
    output(pos[x]);
    printf("%d ",b[x]);
}
int main() 
{
    int n,m,Max = 0;
    scanf("%d",&n);
    for(int i = 1;i <= n;i ++) scanf("%d",&a[i]);
    scanf("%d",&m);
    for(int i = 1;i <= m;i ++) scanf("%d",&b[i]);

    for(int i = 1,t = 0;i <= n;i ++,t = 0)
        for(int j = 1;j <= m;j ++) 
        {
            if(a[i] == b[j]) f[j] = f[t] + 1,pos[j] = t;//f[j] 的結尾
            if(a[i] > b[j] && f[t] < f[j]) t = j;// 保證t在結尾位置
        }
    for(int i = 1;i <= m;i ++)
        if(f[i] > f[Max]) Max = i;
    printf("%d\n",f[Max]);
    output(Max);
    return 0;
}

至此,我們已經討論了\(LIS\)\(LCS\)\(LCIS\)三種基礎的動態規劃類型,算法與算法之間的聯繫可見一斑了


致謝

  • 感謝洛谷上的部分優質題解,我從題解中找到了很多的思路,受教良多
  • \(Liang\)\(Shine\)\(Sky\) 大佬的指點
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章