动态规划入门:从记忆化搜索到动态规划

在开始对动态规划的讲解之前,我们需要先对记忆化搜索进行回顾:

什么是记忆化搜索?

在搜索过程中,当搜索树中存在大量重复的节点时,我们可以通过引入一个"备忘录"(通常是一个数组或哈希表)来优化计算。这个备忘录会记录第一次搜索到某个节点时的计算结果。当后续再次遇到相同的节点时,就可以直接从备忘录中获取之前存储的结果,避免了重复计算的开销。

例如,在计算斐波那契数列时,fib(5) = fib(4) + fib(3),而fib(4)又需要计算fib(3)+fib(2)。如果不使用记忆化,fib(3)会被重复计算多次。使用记忆化后,每个fib(n)只需计算一次。

递推改递归

在用记忆化搜索解决斐波那契数列问题时,如果我们观察备忘录的填写过程,会发现它是一个从左到右依次填充的过程。具体来说:

  1. 先计算并存储fib(0)和fib(1)的基础值
  2. 然后根据这两个值计算fib(2)
  3. 接着用fib(1)和fib(2)计算fib(3)
  4. 以此类推,每个新值都依赖于前面已计算好的值

这种自底向上的计算方式,实际上就是将递归过程改写成了循环形式的递推。这种改写不仅减少了函数调用的开销,还使得计算过程更加直观。

什么是动态规划?

动态规划(Dynamic Programming)是一种用于解决多阶段决策问题的算法思想。它将复杂问题分解为多个相互关联的子问题(称为状态),并通过保存子问题的解来避免重复计算,从而显著提高算法效率。

动态规划通常适用于具有以下特征的问题:

  1. 最优子结构:问题的最优解包含其子问题的最优解
  2. 重叠子问题:不同的决策序列会到达相同的子问题

从上述描述可以看出,动态规划结合了分治法的思想(将问题分解为子问题)和剪枝的思想(通过存储结果避免重复计算)。典型的应用场景包括:

  • 最短路径问题(如Floyd算法)
  • 背包问题
  • 最长公共子序列
  • 编辑距离计算

(在这篇文章中,笔者只讲解最基础的动态规划,典序应用场景的运用将留到后续更新中)

在递推形式的动态规划中,我们常用下面的专有名词来表述,因此,要学好并利用好动态规划,我们首先要弄清楚每一题中一下的概念:

  1. 状态表示:指 f 数组(备忘录)中,每一个格子所代表的含义。其中,这个数组也被称为dp数组,或者dp表。
  2. 状态转移方程:指 f 数组中,每一个格子是如何利用其他格子推导出来的。
  3. 初始化:在填表之前,根据题目中默认条件或问题的默认初始状态,将 f 数组中若干格子先填上值。
例题辅助理解:

光讲概念我相信很多小伙伴不能很好的理解或是看不懂,接下来,笔者将通过两个例题来帮助理解。

下楼梯:顽皮的小明发现,下楼梯时每步可以走 1 个台阶、2 个台阶或 3 个台阶。现在一共有 N 个台阶,你能帮小明算算有多少种方案吗?(洛谷P10250下楼梯

在没有特殊限制的情况下,下台阶问题可以转化为上台阶问题来处理。具体来说,相当于小明每次可选择上1、2 或 3 级台阶。当我们要到达第 i 级台阶时,可能来自 i - 1、i - 2 或 i - 3 级台阶。基于这一思路,可以自然地推导出对应的状态转移方程。具体的算法流程如下所示:

算法原理:

  1. 状态表示:f[i] 表示走到第 i 级台阶有多少种方案
  2. 状态转移方程:f[i] = f[i - 1] + f[i - 2] + f[i - 3]
  3. 初始化:f[1] = 1 ...根据题意自行填写
  4. 填表顺序:从小到大依次填写
  5. 最终结果:f[n]

代码实现:

cpp 复制代码
int main()
{
	cin >> n;
	f[1] = 1, f[2] = 2, f[3] = 4;
	for (int i = 4; i <= n; i++)
		f[i] = f[i - 1] + f[i - 2] + f[i - 3];

	cout << f[n] << endl;
	return 0;
}

空间优化:通过采用滚动数组技术循环利用数组,可以有效降低空间复杂度。空间优化:通过采用滚动数组技术循环利用数组,可以有效降低空间复杂度。

cpp 复制代码
int main()
{
	cin >> n;
	f[1] = 1, f[2] = 2, f[3] = 4;
	for (int i = 4; i <= n; i++)
		f[i % 4] = f[(i - 1) % 4] + f[(i - 2) % 4] + f[(i - 3) % 4];

	cout << f[n % 4] << endl;
	return 0;
}
数字三角形:观察下面的数字金字塔。写一个程序来查找从最高点到底部任意处结束的路径,使路径经过数字的和最大。每一步可以走到左下方的点也可以到达右下方的点(洛谷P1216数字三角形)。

在题目中,要求我们找到一条最大路径,但在这个问题中,我们不能使用贪心算法(易证,这里不再赘述),此时我们就需要使用动态规划的方式解决,每一个位置 f[i][j] 都是由位置 f[i-1][j-1] 或 f[i-1][j] 移动到的,因此,我们只需要找到max(f[i - 1][j - 1], f[i - 1][j])再加上 a[i][j] 即可找到从起点到达 f[i][j] 位置的最大路径。最后,我们只需要遍历一下 f 数组的最下面一行即可找出最大路径。

算法原理:

  1. 状态表示:f[i][j]表示从[1,1]走到[i,j]位置时,所有方案下的最大权值
  2. 状态转移方程: f[i][j] = max(f[i - 1][j - 1], f[i - 1][j]) + a[i][j]
  3. 初始化:将所有格子全部初始化为负无穷
  4. 填表顺序:仅需保证从上到下即可
  5. 最终结果:最后一行的最大值

代码实现:

cpp 复制代码
int main()
{
	cin >> n;
	memset(f, -0x3f, sizeof f);
	for (int i = 1; i <= n; i++)
		for (int j = 1; j <= i; j++)
			cin >> a[i][j];

	f[1][1] = a[1][1];

	for (int i = 2; i <= n; i++)
		for (int j = 1; j <= i; j++)
			f[i][j] = max(f[i - 1][j - 1], f[i - 1][j]) + a[i][j];

	int max = 0;
	for (int i = 1; i <= n; i++)
		if (f[n][i] > max)max = f[n][i];

	cout << max << endl;
	return 0;
}

空间优化:可以将 f 数组从二维降为一维。由于每次更新时都是从左到右顺序处理,且使用过的旧值不会被重复利用,因此可以不断覆盖原数组,仅保留一维数组来记录最终结果。可以将 f 数组从二维降为一维。由于每次更新时都是从左到右顺序处理,且使用过的旧值不会被重复利用,因此可以不断覆盖原数组,仅保留一维数组来记录最终结果。

cpp 复制代码
int main()
{
	cin >> n;
	memset(f, -0x3f, sizeof f);
	for (int i = 1; i <= n; i++)
		for (int j = 1; j <= i; j++)
			cin >> a[i][j];

	f[1] = a[1][1];

	for (int i = 2; i <= n; i++)
		for (int j = 1; j <= i; j++)
			f[j] = max(f[j - 1], f[j]) + a[i][j];

	int max = 0;
	for (int i = 1; i <= n; i++)
		if (f[i] > max)max = f[i];

	cout << max << endl;
	return 0;
}

谢谢阅读!更新不易,点个赞再走吧

相关推荐
山河君3 小时前
webrtc之语音活动上——VAD能量检测原理以及源码详解
算法·音视频·webrtc·信号处理
THMAIL3 小时前
深度学习从入门到精通 - LSTM与GRU深度剖析:破解长序列记忆遗忘困境
人工智能·python·深度学习·算法·机器学习·逻辑回归·lstm
JuneXcy3 小时前
结构体简介
c语言·数据结构·算法
jiaway3 小时前
【C语言】第四课 指针与内存管理
c语言·开发语言·算法
菩提树下的凡夫3 小时前
瑞芯微RV1126目标识别算法Yolov8的部署应用
java·算法·yolo
多打代码4 小时前
2025.09.05 用队列实现栈 & 有效的括号 & 删除字符串中的所有相邻重复项
python·算法
j_xxx404_6 小时前
数据结构:栈和队列力扣算法题
c语言·数据结构·算法·leetcode·链表
南莺莺6 小时前
假设一个算术表达式中包含圆括号、方括号和花括号3种类型的括号,编写一个算法来判别,表达式中的括号是否配对,以字符“\0“作为算术表达式的结束符
c语言·数据结构·算法·
THMAIL6 小时前
深度学习从入门到精通 - 神经网络核心原理:从生物神经元到数学模型蜕变
人工智能·python·深度学习·神经网络·算法·机器学习·逻辑回归