经典算法详解:最长公共子序列 (LCS) —— 从暴力递归到动态规划完整实现

在算法学习中,最长公共子序列(Longest Common Subsequence,简称 LCS) 是动态规划领域最经典的问题之一。它不仅能帮我们理解 DP 思想,还广泛应用于字符串相似度对比、DNA 基因序列匹配、文本对比等实际场景。

本文将基于你写的 C++ 代码,从暴力递归 → 记忆化递归 → 非递归动态规划 → 打印序列,一步步拆解思路、解析代码,带你彻底吃透 LCS!


一、什么是最长公共子序列?

先明确两个核心概念:

  • 子序列(Subsequence):不需要连续,但顺序保持不变
  • 公共子序列:两个字符串都存在的子序列
  • 最长公共子序列:长度最长的那一个

示例

  • X = {#, A,B,C,B,D,A,B}
  • Y = {#, B,D,C,A,B,A}
  • LCS 结果B C B AB D A B,长度 = 4

分析图解


二、解法 1:纯暴力递归(会重复计算,效率极低)

这是最直观的递归思路,但存在大量重复子问题,计算量爆炸。

代码实现

cpp 复制代码
// 最长公共子序列 ------ 暴力递归
int num = 0; // 统计递归调用次数
int LCSLength(const char* X, const char* Y, int i, int j)
{
	num += 1;
	// 递归出口:任意一个字符串遍历完
	if (i == 0 || j == 0)
	{
		return 0;
	}
	// 字符相等:LCS 长度 +1,同时向前递归
	else if (X[i] == Y[j])
	{
		return LCSLength(X, Y, i - 1, j - 1) + 1;
	}
	// 字符不等:取两种情况的最大值(删 X 最后一个 或 删 Y 最后一个)
	else
	{
		int t1 = LCSLength(X, Y, i - 1, j);
		int t2 = LCSLength(X, Y, i, j - 1);
		return t1 > t2 ? t1 : t2;
	}
}

代码解析

  1. 出口条件i==0j==0,LCS 长度为 0
  2. 字符相等X[i] == Y[j]
    • 说明当前字符属于 LCS
    • 结果 = 左上角子问题 + 1
  3. 字符不等
    • 结果 = max (去掉 X 当前字符,去掉 Y 当前字符)
  4. 缺点
    • 大量重复递归
    • 时间复杂度:指数级 O (2ⁿ)
    • 稍微长一点的字符串就会卡死

三、解法 2:记忆化递归(带备忘录,避免重复计算)

暴力递归效率太低,我们用二维数组 c 存储已经计算过的结果,避免重复递归。

同时增加 s 数组 ,用来记录路径,方便后面打印 LCS。

核心思路

  • c[i][j]:存储 X[0~i]Y[0~j] 的 LCS 长度
  • s[i][j]:记录路径标记
    • 1 = 来自左上角(字符相等)
    • 2 = 来自上方
    • 3 = 来自左方

代码实现

cpp 复制代码
int num = 0;
int LCSLength(const char* X, const char* Y, int i, int j,
			  std::vector<std::vector<int>>& c,
			  std::vector<std::vector<int>>& s)
{
	num += 1;
	if (i == 0 || j == 0)
		return 0;
	
	// 已经计算过,直接返回(备忘录核心)
	if (c[i][j] > 0)
		return c[i][j];

	if (X[i] == Y[j])
	{
		c[i][j] = LCSLength(X, Y, i - 1, j - 1, c, s) + 1;
		s[i][j] = 1; // 标记:来自左上角
	}
	else
	{
		int t1 = LCSLength(X, Y, i - 1, j, c, s);
		int t2 = LCSLength(X, Y, i, j - 1, c, s);
		if (t1 > t2)
		{
			c[i][j] = t1;
			s[i][j] = 2; // 来自上方
		}
		else
		{
			c[i][j] = t2;
			s[i][j] = 3; // 来自左方
		}
	}
	return c[i][j];
}

代码解析

  • c 数组:缓存结果,避免重复递归
  • s 数组:记录每一步的来源方向
  • 效率大幅提升:时间复杂度 O (mn)

四、解法 3:非递归动态规划(标准最优解)

这是工业级标准写法 ,用双层循环填充 DP 表,效率最高、最稳定

核心 DP 公式

cpp 复制代码
if(X[i] == Y[j])
	c[i][j] = c[i-1][j-1] + 1
else
	c[i][j] = max(c[i-1][j], c[i][j-1])

代码实现

cpp 复制代码
int NiceLCSLength(const char* X, const char* Y, int xm, int yn,
	std::vector<std::vector<int>>& c, std::vector<std::vector<int>>& s)
{
	for (int i = 1; i <= xm; ++i)  // 遍历 X
	{
		for (int j = 1; j <= yn; ++j) // 遍历 Y
		{
			if (X[i] == Y[j])
			{
				c[i][j] = c[i - 1][j - 1] + 1;
				s[i][j] = 1;
			}
			else
			{
				if (c[i - 1][j] > c[i][j - 1])
				{
					c[i][j] = c[i - 1][j];
					s[i][j] = 2;
				}
				else
				{
					c[i][j] = c[i][j - 1];
					s[i][j] = 3;
				}
			}
		}
		Print2Vec(c); // 打印 DP 表
	}
	return c[xm][yn];
}

代码解析

  • i=1,j=1 开始逐行逐列填表
  • 字符相等 → 左上角 +1
  • 不等 → 取 max (上,左)
  • 最终答案:cxmyn

五、功能:打印最长公共子序列

根据 s 数组的路径标记,递归回溯输出 LCS。

cpp 复制代码
void LCS(const char* X, int i, int j, const std::vector<std::vector<int>>& s)
{
	if (i == 0 || j == 0)
		return;

	// 1 = 来自左上角,字符属于 LCS
	if (s[i][j] == 1)
	{
		LCS(X, i - 1, j - 1, s);
		printf("%5c", X[i]);
	}
	// 2 = 来自上方
	else if (s[i][j] == 2)
	{
		LCS(X, i - 1, j, s);
	}
	// 3 = 来自左方
	else
	{
		LCS(X, i, j - 1, s);
	}
}

六、测试主函数 + 打印 DP 表

cpp 复制代码
void Print2Vec(const std::vector<std::vector<int>>& c)
{
	int yn = c[0].size();
	int xm = c.size();
	printf("    ");
	for (int i = 0; i < yn; ++i)
		printf("%5d", i);
	printf("\n");
	for (int i = 0; i < xm; ++i)
	{
		printf("%3d ", i);
		for (int j = 0; j < yn; ++j)
			printf("%5d", c[i][j]);
		printf("\n");
	}
	printf("\n---------------------------\n");
}

int main()
{
	const int xm = 7;
	const int yn = 6;
	char X[xm + 1] = { '#','A','B','C','B','D','A','B' };
	char Y[yn + 1] = { '#','B','D','C','A','B','A' };

	// 创建 DP 表和路径表
	std::vector<std::vector<int>> c(xm + 1, std::vector<int>(yn + 1, 0));
	std::vector<std::vector<int>> s(xm + 1, std::vector<int>(yn + 1, 0));

	// 非递归 DP(最优)
	int maxlen = NiceLCSLength(X, Y, xm, yn, c, s);
	cout << "最长公共子序列长度:" << maxlen << endl;

	// 打印序列
	printf("最长公共子序列:");
	LCS(X, xm, yn, s);
	return 0;
}

运行结果

bash 复制代码
最长公共子序列长度:4
最长公共子序列:    B    C    B    A

七、三种方法对比总结

方法 时间复杂度 空间复杂度 特点
暴力递归 O(2ⁿ) O(m+n) 思路简单,效率极低
记忆化递归 O(mn) O(mn) 避免重复计算
非递归 DP O(mn) O(mn) 最优解,推荐使用

八、文章总结

  1. LCS 是动态规划经典模板题,必须掌握
  2. 核心思想:
    • 相等 → 左上角 +1
    • 不等 → max (上,左)
  3. c 数组存长度,s 数组存路径
  4. 标准最优解:非递归 DP,时间 O (mn),空间 O (mn)
  5. 可用于:字符串匹配、基因比对、diff 工具

本文代码均可直接编译运行,非常适合 DP 初学者学习~

相关推荐
JAVA面经实录9176 小时前
Java 数据结构与算法 (终极完整学习文档)
java·数据结构·算法
开源Z8 小时前
LeetCode 42 · 接雨水:从暴力到双指针的三步优化
算法·leetcode
旖-旎8 小时前
《LeetCode 695 岛屿的最大面积 FloodFill DFS 解法》
c++·算法·力扣·深度优先遍历·floodfill
syagain_zsx9 小时前
STL 之 vector 讲练结合
c++·算法
MartinYeung510 小时前
[论文学习]DP2Unlearning:高效且具保证的大型语言模型遗忘框架(基于差分隐私的 LLM Unlearning 方法)
学习·算法·语言模型
Tian_Hang10 小时前
C++原型模式(Protype)
开发语言·c++·算法
bIo7lyA8v10 小时前
算法复杂度的渐进分析与实际运行时间的差异的技术8
算法
yuan1999711 小时前
欧拉梁静力与屈曲计算的 MATLAB 实现(有限差分法 + 解析解)
开发语言·算法·matlab
汉克老师12 小时前
GESP7级C++考试语法知识(二、指数函数(3、综合练习)
c++·算法·数学建模·指数函数·gesp7级·复利