- VisualStudio2022插件的安装及使用-编程手把手系列文章
- pprof-在现网场景怎么用
- C#实现的下拉多选框,下拉多选树,多级节点
- 【学习笔记】基础数据结构:猫树
动态规划 Dynamic Programming (DP) 是算法领域的核心思想之一,却同时也是让许多学习者感到棘手的难点之一。动态规划的难点在于它不是简单的数学推导,也不单纯考验人们的程序设计能力,而更像是一种从思维方式到问题建模的一次深刻训练.
本文将从动态规划的定义出发,深入探讨动态规划的本质、应用场景、核心特点、解题思路以及如何逐步提升解决动态规划问题的能力.
动态规划的核心思想可以被概括为一句话:将一个复杂的问题分解成多个简单的子问题,并通过保存子问题的解来避免重复计算,从而提高求解效率.
换句话说,动态规划适用于解决以下两类问题:
我们都知道斐波那契的通项公式是 \(F(n) = F(n-1) + F(n-2)\)。以计算斐波那契数列的第五项 \(F(5)\) 为例,很显然,\(F(5)\) 就是我们需要求解的大问题,而这个大问题可以被逐步分解为两个小问题,即 \(F(4)\) 和 \(F(3)\) 。只要我们知道了 \(F(4)\) 和 \(F(3)\),那么我们就可以将这两项相加来得到最终的结果。这个就是最优子结构的体现.
那什么时候会出现子问题重叠呢?我们不妨用代码来实现斐波那契数列:
// 函数的定义
int fib(int n){
// 根据斐波那契数列的定义:
// 该数列的第零项为0,第一项为1
if (n == 0) return 0;
if (n == 1) return 1;
// 根据式子写出递归代码。
return fib(n-1) + fib(n-2);
}
// 执行函数
cout << fib(5) << endl;
这是一个很简单的递归算法。通过模拟递归算法我们可以画出一个递归图:
通过观察这个图可以发现,在计算 \(F(5)\) 的时候,\(F(3)\) 被 \(F(5)\) 调用过一次,同时又被 \(F(4)\) 调用一次。相同地,\(F(2)\) 被 \(F(3)\) 调用了两次,被 \(F(4)\) 也调用了一次。我们可以发现这个图中有很多的重复计算,这会大大浪费程序的运行时间。然而这就是所谓的子问题重叠问题.
针对这类型的问题,有什么解决方案呢?最直接的方法就是把已经计算过的答案保存下来,下次再调用的时候直接获取结果就可以了,这种方法就是大家常说的“记忆化”。记忆化搜索的代码如下:
memset(memo, -1, sizeof memo);
int fib(int n) {
// 如果已经计算过,直接返回结果
if (memo[n] != -1) return memo[n];
// 否则,递归计算,并将结果存储在 memo 中
memo[n] = fib(n - 1) + fib(n - 2);
return memo[n];
}
这个代码与普通斐波那契数列唯一的区别就是增加一个 memo 数组,判断某一个值是否已经被计算过。如果已经被计算过就直接返回,而不需要继续递归来消耗时间.
而另一种解决办法就是“递推“,我们可以从小到大,先计算 \(F(0)\) 和 \(F(1)\) 并把答案保存下来,计算 \(F(2)\) 的时候就可以立刻通过将 \(F(0)\) 和 \(F(1)\) 相加得到结果。这种递推的做法就是所谓的动态规划思想。而这么做的代码也更加简洁:
int fib[10005];
fib[0] = 0, fib[1] = 1;
for (int i=2; i<=n; i++)
fib[i] = fib[i-1] + fib[i-2]
cout << f[n] << endl;
因此,动态规划的核心思想就是通过 记录 和 重用 子问题的解,避免重复计算,从而提高求解效率。动态规划是一种典型的 拿空间换时间 的思想(毕竟现在的社会,时间成本比空间成本高太多了).
动态规划与分治思想的主要区别就是分治算法主要应用于子问题不重叠的问题,而针对子问题重叠的问题,动态规划是一个更好的选择.
动态规划的三个基本性质是:最优子结构、子问题重叠 以及 无后效性。前两个性质已经在第一部分提及过了,本部分主要讲解无后效性.
无后效性,又称马尔可夫性,这意味着某阶段的状态一旦确定,就不接受该状态之后决策的影响。即,某状态以后的过程不会影响之前的状态,只与当前的状态有关。用一句话概括就是“过去的事情不会影响未来,只关注当前的状态”.
除了无后效性,动态规划的另一个核心在于 最优性原理,即一个最优解包含其子问题的最优解。这一原理是由理查德·贝尔曼(Richard Bellman)提出。最优性原理是动态规划思想的理论基石.
回到原本斐波那契数列场景,当计算 \(F(5)\) 时,答案只依赖于当前状态 \(F(4)\) 和 \(F(3)\) 的值,与更早的历史状态无关。这就是为什么,我们在计算 \(F(5)\) 的时候可以直接使用 \(F(3)\) 和 \(F(4)\) 之前被计算出来的结果相加得到答案,而不是从 \(F(1)\) 和 \(F(0)\) 开始重新计算.
与此同时,在具有无后效性的场景中,一旦某一个状态的值已经被计算下来并可以转移到下一个状态时,这个状态将不会再被改变。换句话说,某状态以后的过程不会影响以前计算出来的状态,只与当前的状态有关.
无后效性是避免重复计算子问题的前提条件.
在看到这里,你会发现动态规划和递归这两个思想有着异曲同工之妙。事实上,大多数的动态规划问题都可以使用记忆化搜索(递归)来解决。动态规划和暴力递归的关键区别如下:
因此广义而言。带有记忆化搜索的递归算法也可以被称之为是动态规划.
解决动态规划问题的基本思路如下:
根据题目的特性,我们可以把一个问题拆解成小问题,将一个小问题拆分成一个个次小问题,以此类推。那么在这之中,每一个需要解决的问题就被称之为一个阶段(状态)。因此对于动态规划问题,第一步需要做的就是尝试如何把大问题分解成许多个有关联的问题。同时,我们需要找到每一个阶段之间都是通过什么变量关联起来的。在斐波那契数列问题中,变量就是斐波那契数列的项数.
在动态规划中,我们把每一个“阶段”称之为一个状态。例如,\(F(5)\) 就可以被称之为是一个状态。我们需要搞清楚大问题的状态和小问题的状态之间的关联.
在斐波那契数列中,这个关联就是:序列的第 \(i\) 项为序列的第 \(i-1\) 和第 \(i-2\) 项的和。用数学表示就是 \(F(n) = F(n-1) + F(n-2)\)。在其他的例子中(更加现实一点的),状态转移方程往往与决策有着密不可分的关系(例子请参考【E.g. 03 - 数楼梯】).
在设计状态转移方程式时,你需要包括所有可能涉及到的变量。以确保能在后续的程序实现步骤中保留所有的可能性.
将问题分解成不能够再被分解的问题,这些问题被称之为最小问题。对于每一个最小问题,应当可以做到直接得出答案,而不需要通过计算.
例如在斐波那契数列中,所谓的最小问题就是 \(F(0)\) 和 \(F(1)\) 的值。这两个值的计算不需要依赖于任何的递推式子,而是根据定义或者逻辑直接给出的.
因此我们需要设置 \(F(0)\) 和 \(F(1)\) 的初始值,并且将这两个状态设置成边界条件.
题目描述 。
一个楼梯有 \(N\) 级台阶,上楼可以一步上一阶,也可以一步上二阶。请你编写一个程序,计算走到第 \(N\) 级台阶共有多少种不同的走法.
解题思路 。
这是一道动态规划的经典例题,对于初学者而言可能对这道题目没有任何的头绪,让我们按照【解决动态规划问题的基本思路】一步一步来解决这道题.
首先确定不同的阶段,对于这道题而言,很显然不同的的阶段就是走到不同阶数的方案数,而当前楼梯的阶级就是唯一的变量。每上一层楼梯,这个变量就会增加。我们不妨定义 \(dp_i\) 表示走到第 \(i\) 阶楼梯的方案数.
接下来确定变量和状态转移方程,在这个场景中,我们思考每一个状态之间的关联。假设我们要知道走到第十级台阶的方案数,但是我们知道想要走到第十级台阶,我们只有两个可能性(决策):
除了这两种方法,我们没有选择。换句话说,走到某一级台阶的方案数只跟走到这之前一级和之前两级台阶的方案数有关系(无后效性),并且方案数就是这两种可能性相加的和(逻辑上也是这样子的)。用一个更宽泛的式子来描述就是:
而这个式子就是所谓的状态转移方程式.
因此,在设计状态转移方程式的时候我们着重观察“可能性”和“决策”这两个关键点.
最后就是确定初始状态和边界条件了,根据题目要求,状态的边界条件在 \(1\le i \le n\):
最后,本题的 C++ 标准代码如下:
#include <iostream>
using namespace std;
int n, dp[35];
int main(){
dp[1] = 1, dp[2] = 2;
cin >> n;
for (int i=3; i<=n; i++)
dp[i] = dp[i-2] + dp[i-1];
cout << dp[n];
return 0;
}
题目描述 。
给定两个字符串 X 和 Y,求出这两个字符串的最长公共子序列 LCS 的长度。注意,这里的子序列不要求连续,但必须保持顺序.
样例输入输出:
输入: X = "ABCBDAB", Y = "BDCAB"
输出: LCS长度 = 4,LCS = "BCAB"
解题思路 。
这道题相比较前面的题目难度有一定的提升,我们依旧按照前文提到的基本思路来一步一步地解决这个动态规划问题.
首先来确定不同的状态(阶段),我们的目标是找出两个字符串 X 和 Y 的最长公共子序列。为了将问题分解,我们可以定义阶段和变量如下:
X[0:i]
和 Y[0:j]
(即 X
的前 i
个字符和 Y
的前 j
个字符)。i
和 j
用于表示字符串 X
和 Y
的子问题范围。在每个阶段,我们的任务是确定 X[0:i] 和 Y[0:j] 的最长公共子序列的长度。这就将整个问题分解为多个规模更小的子问题.
因此,我们考虑用 \(dp_{i, j}\) 来表示字符串 X[0:i] 和 Y[0:j] 的最长公共子序列的长度.
看到这里,很多人会有些许疑问(其实我当初也对这个状态的定义感到困惑,但在经过大量的实践后我发现这确实是最好的状态定义方法。事实上,我认为状态的定义在某种程度上确实是一种玄学,如果实在没有办法搞懂为什么这么定义的话就直接理解就行,不需要刨根问底。但我依然准备了几点理由):
为什么定义阶段为 X[0:i] 和 Y[0:j]?
原因 1:递归思想的启发 。
公共子序列的性质决定了大问题可以由小问题递归构建:
X[0:i-1]
和 Y[0:j-1]
的最长公共子序列,那么我们可以根据 X[i-1]
和 Y[j-1]
的关系推导出 X[0:i]
和 Y[0:j]
的结果。原因 2:易于表示动态变化 。
通过前缀的定义,我们可以在任意时刻只关注子字符串 X[0:i] 和 Y[0:j]:
为什么选择子字符串的前缀?
原因 1:动态规划的自底向上特性 。
动态规划要求我们从最小的问题开始求解,而前缀是自然的分解方式:
X[0:i]
和 Y[0:j]
,最小的子问题是 i=0
或 j=0
,即两个字符串中任意一个为空时,公共子序列长度为 0。原因 2:控制问题规模 。
通过逐步扩展前缀,我们可以在二维表格 dp[i][j] 中用已知的小问题结果构造更大的问题:
X
和 Y
的任意部分(如子序列中间),那么问题之间的关联关系会变得复杂,动态规划就难以实施。接下来就到了第二步,确定状态转移方程式。通过考察字符串 X 和 Y 的最后一个字符,我们先把所有的可能性的找出来:
【情况一】当 X[i-1] == Y[j-1] 时(即两个字符串的最后一个字符相同):
这意味着这两个字符可以成为最长公共子序列的一部分。因此,我们可以将这两个字符加入到之前计算的最长公共子序列中。因此可得 \(dp_{i, j} = dp_{i-1, j-1}\)。 这里的 dp[i][j] 表示字符串 X 的前 i 个字符和字符串 Y 的前 j 个字符的最长公共子序列的长度.
dp[i-1][j-1] 表示在不考虑这两个最后字符的情况下,字符串 X 的前 i-1 个字符和字符串 Y 的前 j-1 个字符的最长公共子序列的长度。由于 X[i-1] 和 Y[j-1] 相同,我们将这两个字符加入到子序列中,因此长度增加了 1.
【情况二】当 X[i-1] != Y[j-1] 时(即两个字符串的最后一个字符不同):
由于最后一个字符不同,我们无法同时将它们加入到公共子序列中。因此,我们需要决定是排除 X 的最后一个字符还是排除 Y 的最后一个字符,以找到更长的公共子序列。因此可得 \(dp_{i, j} = \max(dp_{i-1, j}, dp_{i, j-1})\).
dp[i-1][j] 表示排除 X 的最后一个字符后,字符串 X 的前 i-1 个字符和字符串 Y 的前 j 个字符的最长公共子序列的长度.
dp[i][j-1] 表示排除 Y 的最后一个字符后,字符串 X 的前 i 个字符和字符串 Y 的前 j-1 个字符的最长公共子序列的长度.
我们取这两者中的较大值,确保选择了能够形成最长子序列的路径.
最后就是处理边界了和初始条件了,当其中一个字符串为空时(即 i==0 || j == 0),则公共子序列的长度为零.
最后,本题的 C++ 代码如下:
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 100005;
int a[N], b[N], dp[N][N];
int main(){
int n; cin >> n;
for (int i=1; i<=n; i++) cin >> a[i];
for (int i=1; i<=n; i++) cin >> b[i];
for (int i=1; i<=n; i++){
for (int j=1; j<=n; j++){
if (a[i] == b[j]){
dp[i][j] = dp[i-1][j-1]+1;
} else{
dp[i][j] = max(dp[i-1][j], dp[i][j-1]);
}
}
}
cout << dp[n][n] << endl;
return 0;
}
题目描述 。
给定一个长度为 \(N\) 的序列 \(S = a_1, a_2, \cdots, a_n\),请求求出这个序列的最长上升子序列。子序列不要求连续,但每一个元素出现的顺序需要与原序列中元素出现的顺序对应.
解题思路 。
按照相同的方法,我们先尝试把这个大问题分解成小问题。通过观察题目,注意到长度为 \(N\) 的序列的最长上升子序列一定是由一个更短的序列添加一个元素得来的,因此我们只需要计算将这个元素拼接到哪一个更短的序列中可以得到一个更长的上升子序列即可。因此可以定义 \(dp_i\) 为以第 \(i\) 个元素为结尾的最长上升子序列的长度.
接下来是状态转移方程,根据上述的定义可以得到 \(dp_i = \max_{S_j < S_i, 1\le j < i}(dp_j + 1)\)。该如何理解呢?
为了计算 \(dp_i\),我们需要分析以 \(S_i\) 为结尾时,最长上升子序列的可能性。对于每一个 \(S_i\),我们需要考虑之前所有的元素(即 \(S_1\) 到 \(S_{i-1}\)),是否可以成为 \(S_i\) 的前驱:
如果 \(S_j < S_i\) 且 \(j < i\),说明 \(S_i\) 可以接在以 \(S_j\) 为结尾的最长上升子序列后面,构成一个更大的上升子序列.
那么这个更大的上升子序列的长度就可以表示为:
最后就是初始值了,我们需要一开始把 dp 数组中的所有元素都设置为 \(1\),因为无论如何,数组中的任意一个元素都可以自成一个长度为 \(1\) 的上升子序列.
最后 C++ 代码如下:
#include <iostream>
using namespace std;
const int N = 10005;
int n, count, ans;
int arr[N], dp[N];
int main(){
cin >> n;
for (int i=1; i<=n; i++){
cin >> arr[i]; dp[i] = 1;
}
for (int i=1; i<=n; i++){
for (int j=1; j<i; j++){
if (arr[i] > arr[j]){
dp[i] = max(dp[i], dp[j]+1);
}
}
}
for (int i=1; i<=n; i++)
ans = max(ans, dp[i]);
cout << ans << endl;;
return 0;
}
题目描述 。
Macw 在一个神奇的国家,这个国家的货币只有 \(1\) 元,\(5\) 元, \(11\) 元三种面值的钞票,现在 Macw 想购买一个价值为 \(n\) 元的物品,请问 Macw 最少需要准备多少张钞票刚好能够凑够 \(n\) 元?
解题思路 。
这道题也是动态规划问题的经典题型。依旧按照上述动态规划“三步曲”,我们先来将问题分解出来,既然我们不知道凑够 \(n\) 元最少需要的钞票数量,但是与【E.g. 03 - 数楼梯】类似,我们可以将问题分解为凑够 \(n-1, n-2, n-3, \cdots, 1\) 元钱所需要的钞票数量,并从小到大来解决。因此我们定义状态 \(dp_i\) 为凑够 \(i\) 元钱所需要的最少钞票数量.
接下来找状态转移方程,依旧寻找可能的可能性。假设我们现在有 \(i\) 元钱,我不知道凑够 \(i\) 元钱所需要的钞票,但是我们知道:
以上就是三种选择性,我们只需要找到所需钞票数量最少的那一个组合就好了,即:
化简可得:
相同地,由于一开始我们不知道凑够某一个特定面值所需要的最少钞票数量,因此我们将 \(dp_i\) 全部赋值为 \(\infty\)。而对于 \(dp_0\) 我们知道凑够 \(0\) 元钱不需要任何一张钞票,因此我们特设 \(dp_0 = 0\) 作为我们的初始值.
最后,我们的 C++ 代码如下:
#include <iostream>
#include <cstring>
using namespace std;
const int N = 2e6 + 10;
int dp[N];
int c[5] = {1, 5, 11} , n;
int main() {
cin >> n;
memset(dp, 0x3f, sizeof(dp));
dp[0] = 0;
for (int i = 1; i <= n; i++) {
for (int j = 0; j < 3 ; j++){
// 如果减去数字后索引出现负数,那么应该跳过循环
if (i >= c[j])
dp[i] = min((dp[i - c[j]] + 1) , dp[i]);
}
}
cout << dp[n] << endl;
return 0;
}
以上就是一些基础的动态规划问题的解决方案。实际上,动态规划问题是算法和程序设计竞赛中的非常一大块内容,难度较高的动态规划问题通常与其他算法一起出现,也有许多不同的优化方法降低动态规划的时间复杂度和空间复杂度(例如线段树优化 DP、斜率优化 DP、单调队列优化 DP 等).
说句实话,在我刚学习动态规划问题的时候也是一知半解,一直是一个一知半解的状态,许多题目也都是凭感觉做的。但随着刷题量越来越多,我也渐渐发现部分动态规划是有迹可循的,大部分题目都可以套类似的状态的定义和状态转移方程。毕竟动态规划这个思想本身也特别难懂,还是得需要自己去多练习才可以发现规律和找到解题技巧.
总而言之,动态规划是一种强大的算法设计方法,适用于具有最优子结构、无后效性和子问题重叠性质的问题。该思想通过将复杂问题分解为子问题,记录并重用子问题的解,动态规划能够高效地求解多阶段决策问题。理解并掌握动态规划的本质,对于解决复杂问题和优化算法性能具有重要意义.
GeeksforGeeks. (n.d.). Dynamic Programming. Retrieved from https://www.geeksforgeeks.org/dynamic-programming/ 。
Gupta, R. (2023). Mastering Dynamic Programming: A Step-by-Step Guide. Medium. Retrieved from https://medium.com/@connectto.guptaraghav/mastering-dynamic-programming-a-step-by-step-guide-741d9d7d142 。
Stanford University. (n.d.). Dynamic Programming. Retrieved from https://web.stanford.edu/class/cs97si/04-dynamic-programming.pdf 。
Stanford University. (2013). Dynamic Programming Lecture Notes (CS161). Retrieved from https://web.stanford.edu/class/archive/cs/cs161/cs161.1138/lectures/16/Small16.pdf 。
Bellman, R. (1966). Dynamic Programming. Science, 153(3731), 34–37. doi:10.1126/science.153.3731.34 。
最后此篇关于【知识点】一文讲清动态规划的本质的文章就讲到这里了,如果你想了解更多关于【知识点】一文讲清动态规划的本质的内容请搜索CFSDN的文章或继续浏览相关文章,希望大家以后支持我的博客! 。
我正在创建我的第一个 WAR 文件。我一直在试验 ant buildfile 语法,我的 buildfile 的第一部分从我的 Eclipse 项目中获取内容并将其放入 /dist 文件夹中,然后将其
我是一名学习 SQL 和 PHP 的学生,我接到了一项任务,要使用 PHP 和 mySQLi 创建学生反馈表,我真的一直在思考如何为项目设计数据库! 我正在创建一个系统,用户可以在其中登录网页,如果用
这个问题在这里已经有了答案: Is it possbile to test for expected errors when the testee exits with failure using
我目前正在设计和开发一个 Web 应用程序,该应用程序有可能快速增长。我将提供一些一般信息,然后继续我的问题。我会说我是一名中级网络程序员。 以下是一些规范:MySQL - 数据库后端PHP - 用于
我不知何故无法在我的日志解析器应用程序中实现报告功能。 这是我目前所做的: 我正在编写一个应用程序,它读取日志文件并在字符串中搜索可以在用户配置文件中定义的多个正则表达式。对于从配置中解析的每个所谓的
我有兴趣学习如何在多开发团队场景中设计/规划 Web 应用程序开发。 假设“项目经理/负责人”的角色: 成功的 Web 应用程序开发需要哪些“文档”? 需要什么 UML 图,需要什么程度? 在设计/计
table a (t_a): id name last first email state country 0 sklass klass steve
我们建立了一个广泛使用 JQuery UI 的 AJAX 网站。我们有 30 多个自制的 JQuery UI 小部件(动态加载)。我们到处都使用 JQuery native 小部件:对话框、 slid
我是一名优秀的程序员,十分优秀!