最长公共子序列(LCS)问题分析

最长公共子序列(LCS)网上的资料很多,而且也可以算是动态规划里面的一个基本问题,它体现了许多动态规划的特性,算法导论这本书也给出了很详细的说明。

要写好动态规划其实还是有些难度,网上也很多相关的资料,这里我也就只是想写一篇自己个人的笔记,整理下自己学的一些东西。


问题描述

输入:对于一个字符串(序列),将其中若干个字符(元素)去除后剩余的字符串(序列),我们称之为子序列(注意:子序列与子串是两个不同的概念,子串是原有序列中某个长度的连续元素),现在给定两个字符串,公共子序列则是指两个字符串的相同的子序列
输出:最长的子序列

问题分析

分解问题

如果要直接开始求解,其实还是有些复杂的,即使我们知道使用动态规划可以解决这个问题,后面的分析我们可以发现,要得到这个答案,我们需要做的事情很多。所以我们将这个过程一步步分解。
我们先试着去求解两个最长子序列的长度,后面再考虑如何去构造这个子序列,这样难度可以得到减少。
当然,为什么我们会先考虑求解两个子序列的长度,这就需要自己想了(其实我自己也想不明白)。

定义状态与解的描述

动态规划,说白了也是一类搜索问题。
使用动态规划技术去分析问题,需要特定的步骤,特定的分析手段,这个在《算法导论》书上有比较详细的分析。

首先,我们需要描述这个问题的最优子结构,也就是常说的状态。

我们使用三元组{a(m), b(n), l(m,n)}来描述,a(m)和b(m)为长度分别为m和n的子序列a、b,l(m,n)表示两者的最长公共子序列的长度。
然后,我们就可以知道,如果我们已经知道a(m)和b(n)的最长公共子序列长度为l(m,n),a(m+1)和b(n)的最长公共子序列长度为l(m+1,n),以及a(m)和b(n+1)的最长公共子序列长度为l(m,n+1)。
那对于a(m+1)和b(n+1)的最长公共子序列长度为l(m+1,n+1)的最长公共子序列则有三种情况:
当a[m+1]==b[n+1]时,l(m+1,n+1) = l(m,n)+1 
当a[m+1]!=b[n+1]时,l(m+1,n+1) = max{l(m+1,n), l(m,n+1)}
最后得到的状态转移方程(看成一个递归方程也行)就如下图所示(注:该图取自网络,所使用的字符和下标和上文不同,但是思路相同)



这样我们可以获得一个如下的状态图。
这个状态图有一些很特殊的点,这也是动态规划的一些特点:
(1) 问题的解其实在树的根部
(2) 根部的解需要由其子节点的解进行合并而成
(3) 叶子节点是一个最小子问题,可以理解成我们可以使用肉眼观测出结果的一个问题规模,这很重要,如果使用递归实现,我们很容易发现这就是递归的终止条件。
最长公共子序列(LCS)问题分析_第1张图片


求解过程

通过上述的过程,我们找到了一种描述解的一种方法,那剩下的问题就是,如何去获得这个解。我们知道一个特性,就是解在于整棵树的根部,叶子节点则是我们的初始解。

剩下的问题就是我们的搜索策略问题,对于动态规划,一般存在两种策略——递归、递推。
能够使用递推进行实现的原因在于解在于这个树的根部,而我们却很容易知道叶子节点的解是多少,这样,我们使用“自底向上”的策略,我们就可以一层层的去靠近最终解。如果最终解在叶子节点上,递推这种方法就没办法了。
幸运的是,因为动态规划技术的特性,所有动态规划问题(最少是我遇到的所有动态规划问题),都符合上述的所说的特点,所以我们都可以使用递推的方式进行实现。
所以我们求解动态规划,一般会选择递推进行实现。

接下来,我们先讨论递归实现时候的分析过程。

递归实现下的剪枝(1)

一种暴力的做法就是,我们将整棵树遍历一遍就可以得到答案,但是这种明显不现实。认真观察上图,我们发现,
如果我们发现a[m]==b[n],那我们就没有必要去求解后面的两棵子树。
如果我们发现a[m]!=b[n],那我们就没有必要求解前面的这个子树,而只需要在后面的两棵子树上搜索即可。

当然,其实要得到这个递归实现的代码并不用上述的这么复杂,只要懂得如何使用递归去描述一个递归方程即可,所以其实我们可以直接翻译上述的状态转移方程就可以很容易的得到如下代码:

public class LCS {

	public static int lcsR(char[] a, int m, char[] b, int n) {
		// 到达叶子节点,则可以直接求解
		if (m == 0 || n == 0) 
			return 0;
		
		// 剪枝(1)
		if (a[m-1] == b[n-1]) {
			return lcsR(a, m-1, b, n-1) + 1;
		} else {
			return Math.max(lcsR(a, m-1, b, n), lcsR(a, m, b, n-1));
		}
	}
	public static void main(String[] args) {
		String a = "BDCABA";
		String b = "ABCBDAB";
		int ret = lcsR(a.toCharArray(), a.length(), b.toCharArray(), b.length());
		System.out.println(ret);
	}
}



递归实现下的剪枝(2)

上述的代码其实存在大量的优化空间,为什么?我们看下图,绿色的子树被我们重复求解了三次,如果再展开下去,我们会发现大量的重复求解,所以我们有必要对这些子树进行剪枝。

那现在的问题就是如何进行这些子树的剪枝操作?
这些重复的子树在不同的分支上,所以我们没办法使用http://blog.csdn.net/minghe_uestc/article/details/15224517 这篇博客中提到的剪枝方法。
那我们可以做的事情就是将不同子树的求解结果保存下来(因为每棵子树的解都位于其根部,所以我们可以进行记录),也就是所谓的“记忆搜索”。

(注意:下图中中间的那个绿色子树画错了,应该往右边挪一个,画的时候没注意)


最长公共子序列(LCS)问题分析_第2张图片

通过这种修改,我们得到新的代码如下:

public class LCS {
	// 用于记录状态
	public static int[][] state = null;
	
	// 初始化状态
	public static void init(int m, int n) {
		state = new int[m+1][n+1];
		for (int i = 0; i <= m; i++) {
			for (int j = 0; j <= n; j++) {
				state[i][j] = -1;
			}
		}
	}
	
	public static int lcsR(char[] a, int m, char[] b, int n) {
		if (m == 0 || n == 0) {
			state[m][n] = 0;
			return 0;
		}
		
		if (a[m-1] == b[n-1]) {
			// 通过记忆搜索进行剪枝,如果没有相关记忆,则求解该子树并保存结果
			if (state[m-1][n-1] == -1) 
				state[m-1][n-1] = lcsR(a, m-1, b, n-1);
			state[m][n] = state[m-1][n-1] + 1;
		} else {
			// 通过记忆搜索进行剪枝,如果没有相关记忆,则求解该子树并保存结果
			if (state[m-1][n] == -1) 
				state[m-1][n] = lcsR(a, m-1, b, n);
			if (state[m][n-1] == -1) 
				state[m][n-1] = lcsR(a, m, b, n-1);
			state[m][n] = Math.max(state[m-1][n], state[m][n-1]);
		}

		return state[m][n];
	}
	
	public static void main(String[] args) {
		String a = "BDCABA";
		String b = "ABCBDAB";
		init(a.length(), b.length());
		int ret = lcsR(a.toCharArray(), a.length(), b.toCharArray(), b.length());
		for (int i = 0; i <= a.length(); i++) {  
            for (int j = 0; j <= b.length(); j++) {  
                System.out.printf("%4d", state[i][j]);  
            }  
            System.out.println("");  
        }  
	}
}




递归实现的其他讨论

对于递归实现,我们还有其他的优化手段,比如转换为非递归来提高状态转移的效率,也可以查看是否可以进一步减少状态数量等等,不过这里我们就不继续讨论,因为动态规划更常见的还是使用递推实现,效率要高很多,我们没必要在递归上做过多讨论。

另外,对于如何构造解,递归和递推使用的手段都一样,所以这部分内容我们放在下文中进行描述。

递推实现

使用递推实现,要求我们能够知道初始解,典型的例子就是斐波那契数列,可以使用递推进行实现。当然,也可以去看背包九讲,感觉讲的也很不错。

这里,我们来看看下面这个图,这个图记录了两个字符串所有状态下解的情况,其中第0行和第0列就是我们所能够肉眼看出来的初始解,也就是上述状态转移图中的叶子节点,然后我们可以观察到,其他每个元素的取值只可能是:
如果a[i]==a[j],则l(i,j)等于其左上角的值+1
如果a[i]!=a[j],则l(i,j)等于其左边元素与上面元素的最大值
这两点从状态转移方程中也很容易看出来。

这样我们就知道了,我们在求解第1行的每个状态的解时候,我们只要从左向右依次求解,我们就可以求解得到第1行的所有状态的值。
我们在求解第2行时,因为我们已经知道第1行的结果,所以第二行也只需要从左向右依次求解即可
如此类推,我们自然而然就可以求解到所有的状态。



有了上述的分析。我们就可以编写如下代码:

public class LCS {
	// 用于记录状态
	public static int[][] state = null;
	
	public static void init(int m, int n) {
		state = new int[m+1][n+1];
		// 只需要初始化初始解即可
		for (int i = 0; i <= m; i++) {
			state[i][0] = 0;
		}
		for (int i = 0; i <= n; i++) {
			state[0][i] = 0;
		}
	}
	
	public static void lcsR(char[] a, int m, char[] b, int n) {
		for (int i = 1; i <= m; i++) {
			for (int j = 1; j <= n; j++) {
				if (a[i-1] == b[j-1]) {
					state[i][j] = state[i-1][j-1]+1;
				} else {
					state[i][j] = Math.max(state[i-1][j], state[i][j-1]);
				}
			}
		}
	}
	
	public static void main(String[] args) {
		String a = "BDCABA";
		String b = "ABCBDAB";
		init(a.length(), b.length());
		lcsR(a.toCharArray(), a.length(), b.toCharArray(), b.length());
		System.out.println(state[a.length()][b.length()]);
	}
}

递推实现中的空间优化

在上述代码中,我们需要使用的空间大小为m*n,这个大小我们能不能进一步缩小?答案是可以。

根据上述的代码,我们很容易就知道,我们求解第i行j列的时候,我们只需要知道第i-1行j列的数据以及第i行j-1列的状态值即可。之后我们在求解第i+1行是,第i-1行以及往上的状态值是不需要知道的,所以我们只需要每次保存两行的状态值即可。

通过这种方式,我们得到的代码如下,进一步的优化就是,将需要存储的行减小到min{m,n},这里就不做实现了

public class LCS {
	public static int[] state1 = null;
	public static int[] state2 = null;
	
	public static void init(int n) {
		state1 = new int[n+1];
		state2 = new int[n+1];
		state2[0] = 0;
		for (int i = 0; i <= n; i++) {
			state1[i] = 0; 
		}
	}
	
	public static void lcsR(char[] a, int m, char[] b, int n) {
		for (int i = 1; i <= m; i++) {
			for (int j = 1; j <= n; j++) {
				if (a[i-1] == b[j-1]) {
					state2[j] = state1[j-1] + 1;
				} else {
					state2[j] = Math.max(state1[j], state2[j-1]);
				}
			}
			
			// 移位让state1保存上一行的状态值
			int[] tmp = state1;
			state1 = state2;
			state2 = tmp;
		}
	}
	
	public static void main(String[] args) {
		String a = "BDCABA";
		String b = "ABCBDAB";
		init(b.length());
		lcsR(a.toCharArray(), a.length(), b.toCharArray(), b.length());
		System.out.println(state2[b.length()]);
	}
}

构造解

在有了上述代码以后,接下来就是如何去构造解。

public class LCS {
	public static int[] state1 = null;
	public static int[] state2 = null;
	public static char[][] c = null;
	
	public static void init(int m, int n) {
		state1 = new int[n+1];
		state2 = new int[n+1];
		state2[0] = 0;
		for (int i = 0; i <= n; i++) {
			state1[i] = 0; 
		}
		
		c = new char[m+1][n+1];
		for (int i = 0; i <= m; i++) {
			for (int j = 0; j <= n; j++) {
				// 字符X表示无
				c[i][j] = 'X';
			}
		}
	}
	
	public static void lcsR(char[] a, int m, char[] b, int n) {
		for (int i = 1; i <= m; i++) {
			for (int j = 1; j <= n; j++) {
				if (a[i-1] == b[j-1]) {
					state2[j] = state1[j-1] + 1;
					// N表示左上
					c[i][j] = 'N';
				} else {
					if (state1[j] > state2[j-1]) {
						state2[j] = state1[j];
						// U表示上
						c[i][j] = 'U';
					} else if (state1[j] < state2[j-1]) {
						state2[j] = state2[j-1];
						// L表示左
						c[i][j] = 'L';
					} else {
						state2[j] = state2[j-1];
						// A表示上或者左
						c[i][j] = 'A';
					}
				}
			}
			
			// 移位让state1保存上一行的状态值
			int[] tmp = state1;
			state1 = state2;
			state2 = tmp;
		}
	}
	
	public static void main(String[] args) {
		String a = "BDCABA";
		String b = "ABCBDAB";
		init(a.length(), b.length());
		lcsR(a.toCharArray(), a.length(), b.toCharArray(), b.length());
		System.out.println(state2[b.length()]);
		for (int i = 0; i <= a.length(); i++) {
			for (int j = 0; j <= b.length(); j++) {
				System.out.print(c[i][j] + " ");
			}
			System.out.println("");
		}
	}
}

通过运行,得到的输出结果是:
4
X X X X X X X X 
X A N L N L L N 
X A U A A N L L 
X A U N L A A A 
X N A U A A N L 
X U N A N L A N 
X N U A U A N A 

上述的结果或许还有些难懂,我们将这个画成下面的这个图,沿着折线进行游走,图中经过的绿色节点就是我们的解,从图中我们知道总共有三个解:"BCBA"、"BCAB"、"BDAB"

最长公共子序列(LCS)问题分析_第3张图片
因此只要知道了这个数组,我们就可以构造一个单向的图出来,我们只要遍历从终点出发的所有路径,并且记录这些路径中经过的所有N节点即可。

下面的代码就是根据这个过程,构建一个解进行返回:
如果需要构建多个解返回,那就需要遍历整棵树才行,这里的代码仅仅只是遍历某个分支(一个分支就表示一个解)。
另外,还可以将这个递归实现修改成非递归,因为是线性递归,所以修改成线性递归还是很容易的,这里就不讨论怎么修改了。

public class LCS {
	public static int[] state1 = null;
	public static int[] state2 = null;
	public static char[][] c = null;
	
	public static void init(int m, int n) {
		state1 = new int[n+1];
		state2 = new int[n+1];
		state2[0] = 0;
		for (int i = 0; i <= n; i++) {
			state1[i] = 0; 
		}
		
		c = new char[m+1][n+1];
		for (int i = 0; i <= m; i++) {
			for (int j = 0; j <= n; j++) {
				// 字符X表示无
				c[i][j] = 'X';
			}
		}
	}
	
	public static void lcsR(char[] a, int m, char[] b, int n) {
		for (int i = 1; i <= m; i++) {
			for (int j = 1; j <= n; j++) {
				if (a[i-1] == b[j-1]) {
					state2[j] = state1[j-1] + 1;
					// N表示左上
					c[i][j] = 'N';
				} else {
					if (state1[j] > state2[j-1]) {
						state2[j] = state1[j];
						// U表示上
						c[i][j] = 'U';
					} else if (state1[j] < state2[j-1]) {
						state2[j] = state2[j-1];
						// L表示左
						c[i][j] = 'L';
					} else {
						state2[j] = state2[j-1];
						// A表示上或者左
						c[i][j] = 'A';
					}
				}
			}
			
			// 移位让state1保存上一行的状态值
			int[] tmp = state1;
			state1 = state2;
			state2 = tmp;
		}
	}
		
	// 构建一个解返回
	public static String getRet(char[] a, int m, char[] b, int n) {
		if (m == 0 || n == 0) 
			return "";
		
		String ret;
		if (c[m][n] == 'N') {
			ret = getRet(a, m-1, b, n-1) + a[m-1];
		} else if (c[m][n] == 'L') {
			ret = getRet(a, m-1, b, n);
		} else if (c[m][n] == 'U') {
			ret = getRet(a, m, b, n-1);
		} else {
			// 两个方向皆可以,随便选一个
			ret = getRet(a, m-1, b, n);
		} 
		return ret;
	}
	
	public static void main(String[] args) {
		String a = "BDCABA";
		String b = "ABCBDAB";
		init(a.length(), b.length());
		lcsR(a.toCharArray(), a.length(), b.toCharArray(), b.length());
		System.out.println(state2[b.length()]);
		for (int i = 0; i <= a.length(); i++) {
			for (int j = 0; j <= b.length(); j++) {
				System.out.print(c[i][j] + " ");
			}
			System.out.println("");
		}
		
		System.out.println(getRet(a.toCharArray(), a.length(), b.toCharArray(), b.length()));
	}
}




你可能感兴趣的:(算法)