1. 程式人生 > >【演算法】動態規劃法——最長公共子序列(LCS)

【演算法】動態規劃法——最長公共子序列(LCS)

前言

       這篇是自己寫的第一篇關於演算法方面的部落格,寫他是因為自己今天開啟筆記,剛好看到了它,就這麼簡單。

       這篇部落格主要想講講動態規劃法,然後以LCS問題為例展開來說一下怎麼利用動態規劃法求解它,下面是自己的一些理解和總結,有不對的地方還請大家指正。

動態規劃法

       動態規劃法(dynamic programming)通常用於求解最優化問題(optimization problem),它適用於那些子問題相互重疊的情況,即子問題不獨立,不同的子問題具有公共的子子問題(就是子問題的子問題)。這顯然與分治法是不同的,分治法將問題劃分為不重疊的子問題,然後分別求解這些子問題,最後將這些問題合併得到最終的解。

     對於具有公共子問題的情況,分治法會做很多不必要的工作,它會多次求解同一子子問題。動態規劃法卻不一樣,對每個子子問題它只會求解一次,將其儲存在一個表格中,避免了不必要的重複計算。

     如之前所說,動態規劃法用於求解最優化問題,這就意味著可能這個問題,有很多解,但是呢,不一定都是最優解。利用動態規劃法求出來的是這個問題的一個最優解(an optimal solution),記住這裡求解的只是最優解(the optimal solution)中的一個,因為最優解可能有多個。

     設計一個問題的動態規劃演算法主要有一下的幾步

    (1)       找出最優解的性質,刻畫其結構特徵;

    (2)       遞迴的定義最優解的值;

    (3)       以自底向上的方式計算出最優值;

    (4)       根據計算最優解時得到的資訊,構造一個最優解。

      如果你只需要一個最優解的值,而不是這個結本身,就不需要第(4)步。如果你需要得到這個解本身,也就是說你需要執行第(4)步,這往往需要我們在第(3)步中記錄一些額外的資訊,以方便第(4)步的求解。

     下面讓我們來看看LCS問題如何利用動態規劃法求解。

最長公共子序列的動態規劃法實現

最長公共子序列(longest-common-subsequence, LCS)

     (1)子序列:一個序列X = x1

x2...xn,中任意刪除若干項,剩餘的序列叫做A的一個子序列。也可以認為是從序列A按原順序保留任意若干項得到的序列。
      例如:對序列 1,3,5,4,2,6,8,7來說,序列3,4,8,7 是它的一個子序列。對於一個長度為n的序列,它一共有2^n 個子序列,有(2^n – 1)個非空子序列。在這裡需要提醒大家,子序列不是子集,它和原始序列的元素順序是相關的。

    (2)公共子序列:如果序列Z既是序列X的子序列,同時也是序列Y的子序列,則稱它為序列X和序列Y的公共子序列。空序列是任何兩個序列的公共子序列。

     (3)最長公共子序列:X和Y的公共子序列中長度最長的(包含元素最多的)叫做X和Y的最長公共子序列。

      這個問題如果用窮舉法時間,最終求出最長公共子序列時,時間複雜度是Ο(2mn),是指數級別的複雜度,對於長序列是不適用的。因此我們使用動態規劃法來求解。

刻畫最長公共子序列問題的最優子結構

      設X=x1x2…xm和Y=y1y2…yn是兩個序列,Z=z1z2…zk是這兩個序列的一個最長公共子序列。

      1.      如果xm=yn,那麼zk=xm=yn,且Zk-1是Xm-1,Yn-1的一個最長公共子序列;

      2.      如果xm≠yn,那麼zk≠xm,意味著Z是Xm-1,Y的一個最長公共子序列;

      3.      如果xm≠yn,那麼zk≠yn,意味著Z是X,Yn-1的一個最長公共子序列。

      從上面三種情況可以看出,兩個序列的LCS包含兩個序列的字首的LCS。因此,LCS問題具有最優子結構特徵。

遞迴的定義最優值

      從最優子結構可以看出,如果xm=yn,那麼我們應該求解Xm-1,Yn-1的一個LCS,並且將xm=yn加入到這個LCS的末尾,這樣得到的一個新的LCS就是所求。

      如果xm≠yn,我們需要求解兩個子問題,分別求Xm-1,Y的一個LCS和X,Yn-1的一個LCS。兩個LCS中較長者就是X和Y的一個LCS。

      可以看出LCS問題具有重疊子問題性質。為了求X和Y的一個LCS,我們需要分別求出Xm-1,Y的一個LCS和X,Yn-1的一個LCS,這幾個字問題又包含了求出Xm-1,Yn-1的一個LCS的子子問題。(有點繞了。。。暈沒暈。。。。)

       根據上面的分析,我們可以得出下面的公式;

            

計算最優解的值

       根據上面的,我們很容易就可以寫出遞迴計算LCS問題的程式,通過這個程式我們可以求出各個子問題的LCS的值,此外,為了求解最優解本身,我們好需要一個表b,b[i,j]記錄使C[i,j]取值的最優子結構。

       C++程式碼如下;

int **Lcs_length(string X,string Y,int **B)
{
	int x_len = X.length();
	int y_len = Y.length();

	int **C = new int *[x_len+1];
	for (int i = 0; i <= x_len; i++)
	{
		C[i] = new int[y_len + 1];        //定義一個存放最優解的值的表;
	}

	for (int i = 1; i <= x_len; i++)
	{
		C[i][0] = 0;
		B[i][0] = -2;                     //-2表示沒有方向
	}


	for (int j = 0; j <= y_len; j++)
	{
		C[0][j] = 0;
		B[0][j] = -2;
	}
	
	for (int i = 1; i <= x_len; i++)
	{
		for (int j = 1; j <= y_len; j++)
		{

			if (X[i-1]==Y[j-1])
			{
				C[i][j] = C[i - 1][j - 1] + 1;

				B[i][j] = 0;             //0表示斜向左上
			}
			else
			{
				if (C[i-1][j]>=C[i][j-1])
				{
					C[i][j] = C[i - 1][j];
					B[i][j] = -1;       //-1表示豎直向上;
				}
				else
				{
					C[i][j] = C[i][j - 1];
					B[i][j] = 1;        //1表示橫向左
				}
			}

		}
	}
	return C;
}

       將C與b分別輸出的記過如下圖


      將兩個表格畫成一個表格的結果如下;


構造最長公共子序列

      從表格中可以看出,用表b中的資訊可以構建出X和Y的一個LCS。從b[m,n]開始,沿著箭頭的方向追蹤,當箭頭是斜上的時候,表示Xi=Yj;是LCS中的一個元素。C++程式碼如下;

void OutPutLCS(int **B, string X,int str1_len,int str2_len)
{
	
	if (str1_len == 0 || str2_len == 0)
	{
		return;
	}
	if (B[str1_len][str2_len] == 0)   //箭頭左斜
	{
		OutPutLCS(B, X, str1_len - 1, str2_len - 1);
		printf("%c", X[str1_len - 1]);
	}
	else if (B[str1_len][str2_len] == -1)
	{
		OutPutLCS(B, X, str1_len - 1, str2_len);
	}
	else
	{
		OutPutLCS(B, X, str1_len, str2_len-1);
	}
}

       最終輸出的結果是BCBA,但是如之前所說,這只是所有最優解中的一個,明顯BDAB也是一個最優解。

演算法改進

從方法的實現中可以看出計算LCS的過程中時間複雜度是Ο(mn),空間複雜度也是Ο(mn)。在構造最長公共子序列的過程中時間複雜度為Ο(m+n);但是我們可以不適用表b,我們可以直接利用表C的資訊構造LCS

因為C[ij]的值只依賴於三項:C[i-1j]C[ij-1]C[i-1j-1]。這樣就為記憶體節約了一個Ο(mn)空間。但是計算LCS的輔助空間並未減少,因為表C的空間複雜度依然是Ο(mn這部分工作利用C++很容易就能實現,我就不貼出來了。

再談動態規劃法

通過上面的過程想必大家對於動態規劃法已經有了一定的理解。對他的計算效果也肯定是非常認同的。

動態規劃法是一個非常有效的演算法設計技術,它主要用於具有以下兩種特徵的問題。

(1)最優子結構。如果一個問題的最優解中包含了其子問題的最優解,就說該問題具有最優子結構。當一個問題具有最優子結構時,我們就可以考慮使用動態規劃法去實現它。

(2)重疊子問題。重疊子問題是指用來解原問題的遞迴演算法會反覆求解同樣子問題,當一個遞迴演算法不斷地呼叫同一個問題時,就說明該問題包含了重疊子問題。此時如果用分治法求解,會反覆求解同樣的問題,效率低下。

完整程式碼

// 動態規劃法解決最長子序列.cpp : 定義控制檯應用程式的入口點。
//

#include "stdafx.h"
#include <string>
#include <iostream>

#ifndef MAX
#define MAX(X,Y) ((X>=Y)? X:Y)
#endif

using namespace std;

int **Lcs_length(string X,string Y,int **B)
{
	int x_len = X.length();
	int y_len = Y.length();

	int **C = new int *[x_len+1];
	for (int i = 0; i <= x_len; i++)
	{
		C[i] = new int[y_len + 1];        //定義一個存放最優解的值的表;
	}

	for (int i = 1; i <= x_len; i++)
	{
		C[i][0] = 0;
		B[i][0] = -2;                     //-2表示沒有方向
	}


	for (int j = 0; j <= y_len; j++)
	{
		C[0][j] = 0;
		B[0][j] = -2;
	}
	
	for (int i = 1; i <= x_len; i++)
	{
		for (int j = 1; j <= y_len; j++)
		{

			if (X[i-1]==Y[j-1])
			{
				C[i][j] = C[i - 1][j - 1] + 1;

				B[i][j] = 0;             //0表示斜向左上
			}
			else
			{
				if (C[i-1][j]>=C[i][j-1])
				{
					C[i][j] = C[i - 1][j];
					B[i][j] = -1;       //-1表示豎直向上;
				}
				else
				{
					C[i][j] = C[i][j - 1];
					B[i][j] = 1;        //1表示橫向左
				}
			}

		}
	}
	return C;
}

void OutPutLCS(int **B, string X,int str1_len,int str2_len)
{
	
	if (str1_len == 0 || str2_len == 0)
	{
		return;
	}
	if (B[str1_len][str2_len] == 0)   //箭頭左斜
	{
		OutPutLCS(B, X, str1_len - 1, str2_len - 1);
		printf("%c", X[str1_len - 1]);
	}
	else if (B[str1_len][str2_len] == -1)
	{
		OutPutLCS(B, X, str1_len - 1, str2_len);
	}
	else
	{
		OutPutLCS(B, X, str1_len, str2_len-1);
	}
}

int _tmain(int argc, _TCHAR* argv[])
{
	string X = "ABCBDAB";
	string Y = "BDCABA";

	int x_len = X.length();
	int y_len = Y.length();

	int **C;

	int **B = new int *[x_len + 1];
	for (int i = 0; i <= x_len; i++)
	{
		B[i] = new int[y_len + 1];
	}


	C = Lcs_length(X, Y, B);

	for (int i = 0; i <= x_len; i++)
	{
		for (int j = 0; j <= y_len; j++)
		{
			cout << C[i][j]<<" ";
		}
		cout << endl;
	}

	cout << endl;

	for (int i = 0; i <= x_len; i++)
	{
		for (int j = 0; j <= y_len; j++)
		{
			cout << B[i][j] << " ";
		}
		cout << endl;
	}

	OutPutLCS(B, X, x_len, y_len);

	system("pause");
	return 0;

已完。。