经典算法详解:最长公共子序列 (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 (上,左)
  • 最终答案:c[xm][yn]

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

根据 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 初学者学习~

相关推荐
pzx_0012 小时前
【优化器】 随机梯度下降 SGD 详解
人工智能·python·算法
小肝一下2 小时前
每日两道力扣,day8
c++·算法·leetcode·哈希算法·hot100
无限进步_2 小时前
【C++】验证回文字符串:高效算法详解与优化
java·开发语言·c++·git·算法·github·visual studio
Meme Buoy3 小时前
18.补充数学1:生成树-最短路径-最大流量-线性规划
数据结构·算法
paeamecium3 小时前
【PAT甲级真题】- Count PAT‘s (25)
c++·算法·动态规划·pat考试·pat
汀、人工智能3 小时前
[特殊字符] 第89课:岛屿数量
数据结构·算法·数据库架构·图论·bfs·岛屿数量
九英里路3 小时前
cpp容器——string模拟实现
java·前端·数据结构·c++·算法·容器·字符串
卷福同学3 小时前
去掉手机APP开屏广告,李跳跳2.2下载使用
java·后端·算法
漫霂3 小时前
二叉树的翻转
java·数据结构·算法