一、递归
(一)递归的概念
递归是一种在函数定义中调用自身的编程技巧。简单来说,就是一个函数在它的函数体内调用它自身。递归通常用于解决可以被分解为相同性质的子问题的情况,并且这些子问题规模更小,当子问题规模减小到一定程度时,会有一个明确的终止条件。
(二)递归的基本要素
- 递归终止条件:这是递归能够结束的关键。如果没有终止条件,函数会一直调用自身,导致栈溢出错误。例如,计算阶乘时,当
n
等于0
或1
时,阶乘值为1
,这就是终止条件。 - 递归调用:在函数内部调用自身,并且每次调用时问题的规模要有所减小。比如计算
n
的阶乘,n! = n * (n - 1)!
,这里(n - 1)!
就是通过递归调用自身来计算的。
(三)递归示例:计算阶乘
解题思路:
计算阶乘时,我们依据阶乘的定义来编写递归函数。对于 n
的阶乘,当 n
为 0
或 1
时,结果为 1
,这是递归的终止情况。对于其他大于 1
的 n
,其阶乘是 n
乘以 (n - 1)
的阶乘,通过不断递归调用函数来逐步计算。
#include <bits/stdc++.h>
using namespace std;
// 函数用于计算n的阶乘
int factorial(int n) {
// 递归终止条件:当n为0或1时,阶乘结果为1
if (n == 0 || n == 1) {
return 1;
}
// 递归调用:n的阶乘等于n乘以(n - 1)的阶乘
return n * factorial(n - 1);
}
(四)递归的优缺点
- 优点:递归可以用简洁的代码解决一些具有递归性质的问题,如树的遍历等。它的代码结构清晰,符合人们对问题的思考逻辑。
- 缺点:递归可能会导致栈溢出,因为每次递归调用都会在栈中分配空间。而且递归的执行效率相对较低,因为存在大量的函数调用开销。
三、记忆化搜索
(一)记忆化搜索的概念
记忆化搜索是对递归算法的一种优化技术。在递归过程中,很多子问题会被重复计算多次。记忆化搜索通过记录已经计算过的子问题的结果,当再次遇到相同子问题时,直接返回记录的结果,无需重新计算,从而提高算法效率。
(二)记忆化搜索示例:斐波那契数列
解题思路:
斐波那契数列传统递归实现中,像计算 fib(5)
时,fib(3)
等子问题会被重复计算。记忆化搜索使用一个数组(这里是 memo
)来存储已经计算过的结果。每次计算前先查看数组中是否有对应结果,有则直接返回,没有再进行递归计算并存储结果。
#include <bits/stdc++.h>
using namespace std;
const int N = 100;
// 用于存储已经计算过的斐波那契数列结果,初始化为-1表示未计算
vector<int> memo(N, -1);
// 函数用于计算斐波那契数列第n项的值
int fib(int n) {
// 如果memo[n]不等于-1,说明这个子问题已经计算过,直接返回结果
if (memo[n] != -1) {
return memo[n];
}
// 当n为0时,斐波那契数列第0项为0
if (n == 0) {
memo[n] = 0;
}
// 当n为1时,斐波那契数列第1项为1
else if (n == 1) {
memo[n] = 1;
}
// 对于n > 1的情况,斐波那契数列第n项等于第n - 1项加上第n - 2项
// 并且将计算结果记录到memo数组中
else {
memo[n] = fib(n - 1) + fib(n - 2);
}
return memo[n];
}
四、动态规划(DP)
(一)动态规划的概念
动态规划是一种通过把原问题分解为相对简单的子问题,并保存子问题的解来避免重复计算,从而解决复杂问题的方法。它与记忆化搜索有相似之处,但更强调问题的状态定义和状态转移方程的推导。
(二)动态规划的基本步骤
- 定义状态:确定用什么来表示问题的状态,比如在背包问题中,状态可以是当前背包容量和已经考虑的物品数量。
- 推导状态转移方程:找出不同状态之间的关系,即如何从已知状态推导出未知状态。例如,在斐波那契数列中,状态转移方程为
dp[n] = dp[n - 1] + dp[n - 2]
。 - 确定初始条件:明确问题最开始的状态值,如斐波那契数列中
dp[0] = 0
,dp[1] = 1
。
(三)动态规划示例:爬楼梯问题
题目链接:U517028 爬楼梯 - 洛谷
(1)递归解法
- 基本思路:利用递归的思想,将爬
n
级楼梯的问题转化为爬n - 1
级楼梯和爬n - 2
级楼梯这两个子问题。 - 边界条件:当楼梯级数
n
为1
时,只有一种走法(一次走一级 );当n
为2
时,有两种走法(一次走两级或分两次每次走一级 )。 - 递归关系:爬
n
级楼梯的走法数等于爬n - 1
级楼梯的走法数加上爬n - 2
级楼梯的走法数,即climb(n) = climb(n - 1) + climb(n - 2)
。不断递归求解子问题,最终得到爬n
级楼梯的走法数。但此方法存在大量重复计算,时间复杂度较高。
#include <bits/stdc++.h>
using namespace std;
// 递归函数,计算爬n级楼梯的不同走法数
int climb(int n) {
// 边界条件:当n为1时,只有一种走法(一次走一级)
if (n == 1) {
return 1;
}
// 边界条件:当n为2时,有两种走法(一次走两级 或 分两次每次走一级)
if (n == 2) {
return 2;
}
// 递归计算:爬n级楼梯的走法数等于爬n - 1级楼梯的走法数加上爬n - 2级楼梯的走法数
return climb(n - 1) + climb(n - 2);
}
int main() {
int n;
while (cin >> n) { // 持续读取输入的楼梯级数
cout << climb(n) << endl; // 输出爬n级楼梯的不同走法数
}
return 0;
}
(2)记忆化搜索解法
- 基本思路:在递归的基础上,增加记忆化数组
memo
来避免重复计算。 - 记忆化处理:用
memo[i]
记录爬i
级楼梯的走法数。每次进入递归函数dfs
时,先检查memo[i]
是否已经有值(不为-1
),如果有则直接返回该值,避免重复计算子问题。 - 递归计算:若
memo[i]
没有值,则按照与递归调用相同的边界条件和递归关系(n
为1
或2
时是边界条件,dfs(n) = dfs(n - 1) + dfs(n - 2)
是递归关系 )进行计算,计算完后将结果存入memo[i]
,提高后续计算效率。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 35;
// memo数组用于记录已经计算过的结果,memo[i]表示爬i级楼梯的不同走法数
int memo[MAXN];
// 记忆化搜索函数,计算爬n级楼梯的不同走法数
int dfs(int n) {
// 如果已经计算过,直接返回结果
if (memo[n] != -1) {
return memo[n];
}
// 边界条件:当n为1时,只有一种走法(一次走一级)
if (n == 1) {
return 1;
}
// 边界条件:当n为2时,有两种走法(一次走两级 或 分两次每次走一级)
if (n == 2) {
return 2;
}
// 递归计算并记录结果
memo[n] = dfs(n - 1) + dfs(n - 2);
return memo[n];
}
int main() {
int n;
memset(memo, -1, sizeof(memo)); // 初始化memo数组为-1,表示尚未计算
while (cin >> n) { // 持续读取输入的楼梯级数
cout << dfs(n) << endl; // 输出爬n级楼梯的不同走法数
}
return 0;
}
(3)动态规划解法
- 定义状态:定义
dp[i]
表示爬i
级楼梯的不同走法数。 - 初始化状态:明确边界情况,
dp[1] = 1
(爬1
级楼梯只有一种走法 ),dp[2] = 2
(爬2
级楼梯有两种走法 )。 - 状态转移:根据问题的性质,得出状态转移方程
dp[i] = dp[i - 1] + dp[i - 2]
(i > 2
),即爬i
级楼梯的走法数等于爬i - 1
级楼梯的走法数加上爬i - 2
级楼梯的走法数。从i = 3
开始,按照这个方程依次计算出dp
数组后续的值,最终dp[n]
就是爬n
级楼梯的走法数。
#include <bits/stdc++.h>
using namespace std;
int main() {
int n;
const int MAXN = 35;
// dp数组用于存储爬每一级楼梯的不同走法数,dp[i]表示爬i级楼梯的不同走法数
int dp[MAXN];
dp[1] = 1; // 初始化:爬1级楼梯只有一种走法
dp[2] = 2; // 初始化:爬2级楼梯有两种走法
for (int i = 3; i <= 30; i++) {
// 状态转移方程:爬i级楼梯的走法数等于爬i - 1级楼梯的走法数加上爬i - 2级楼梯的走法数
dp[i] = dp[i - 1] + dp[i - 2];
}
while (cin >> n) { // 持续读取输入的楼梯级数
cout << dp[n] << endl; // 输出爬n级楼梯的不同走法数
}
return 0;
}
五、蓝桥杯真题演练
(一)真题 1:对局匹配
题目链接:https://www.lanqiao.cn/problems/107/learning/
解题思路
- 输入处理与初始化:首先读取用户数量
n
和积分差k
。接着读取每个用户的积分,在读取过程中记录最大积分mx
,并使用count
数组统计每个积分出现的次数。count[i]
表示积分值为i
的用户数量。 - 特殊情况处理(k = 0 时):当
k
为 0 时,只要积分不同就不会匹配,那么答案就是不同积分的数量。通过遍历count
数组,统计不为 0 的元素个数,即为答案。 - 一般情况处理(k > 0 时):
-
我们采取分组的方式,将n个人分为k组[0,k),对于第i组,组里的数就有{i,i+k,i+2k...},并用val数组,记录一下每个数具体有几个,至此分组解决了。接着就是解决如何选取的问题,因为组与组之间互不影响(从a组中任选一个数,与它相差k的数必不可能出现在其他组),所以我们就需要把这k组,每一组都选一个人数最多的选法,需要利用动态规划了。
-
我们可以知道,对于一个组中的num个数,相邻的两个数是必不可能选择的,所以很容易得出状态转移方程dp[j] = max(dp[j-1], dp[j-2] + val[j]) , dp[j-1]就代表当前j这个数我不选,dp[j-2]+val[j]就代表j-1我不选,而是选了j。注意处理j=0和1时不要越界即可。
代码实现:
#include<bits/stdc++.h>
using namespace std;
const int N = 1e5 + 5;
int b[N];
int dp[N];
int main()
{
int n, k;
// 读取用户数量n和匹配积分差k
cin >> n >> k;
int a[n + 1];
int mx = 0;
int count[N];
// 将count数组初始化为0,用于统计每个积分值出现的次数
for (int i = 0; i < N; i++) count[i] = 0;
for (int i = 1; i <= n; i++) {
// 读取每个用户的积分a[i]
cin >> a[i];
// 更新最大积分值mx
if (a[i] > mx) mx = a[i];
// 统计积分值为a[i]的用户数量
count[a[i]]++;
}
// 当k为0时,不同积分的用户不会匹配
if (k == 0) {
int sum = 0;
// 统计count数组中不为0的元素个数,即不同积分的数量
for (int i = 1; i <= N; i++) sum += (count[i] != 0);
cout << sum;
return 0;
}
int sum = 0;
// 对积分按照模k的结果分成k组
for (int i = 0; i < k; i++) {
// 初始化b和dp数组
for (int j = 0; j < N; j++) {
b[j] = 0;
dp[j] = 0;
}
int num = 0;
// 将模k余i的积分值按照间隔k存入b数组
for (int j = i; j <= mx; j += k) {
b[num++] = count[j];
}
// 初始化dp[0]为b[0]
dp[0] = b[0];
for (int j = 1; j < num; j++) {
// 动态规划状态转移
if (j == 1) dp[j] = max(dp[0], b[1]);
else dp[j] = max(dp[j - 1], dp[j - 2] + b[j]);
}
// 将每组动态规划得到的最大用户数累加到sum
sum += dp[num - 1];
}
cout << sum;
}
(二)真题 2:最长上升子序列
题目链接:B3637 最长上升子序列 - 洛谷
解题思路:
本题可使用动态规划(DP)的方法来求解最长上升子序列的长度。
- 定义状态:定义一个数组
dp
,其中dp[i]
表示以原序列中第i
个元素结尾的最长上升子序列的长度。初始时,每个元素自身可以构成一个长度为1
的上升子序列,所以dp[i]
都初始化为1
。 - 状态转移方程:对于
i
位置的元素,要找到0
到i - 1
中所有满足a[j] < a[i]
(a
是原序列 )的位置j
,然后更新dp[i]
,即dp[i] = max(dp[i], dp[j] + 1)
。这是因为如果能找到比当前元素小的前面的元素,那么就可以把当前元素接到那个元素结尾的上升子序列后面,长度加1
。 - 遍历求解:通过两层循环遍历原序列,外层循环枚举每个位置
i
,内层循环枚举0
到i - 1
的位置j
,按照状态转移方程更新dp
数组。 - 获取结果:遍历完整个
dp
数组,找到其中的最大值,这个最大值就是整个序列的最长上升子序列的长度。
代码实现:
#include <bits/stdc++.h>
using namespace std;
int main() {
int n;
// 读取序列长度
cin >> n;
vector<int> a(n);
vector<int> dp(n, 1);
// 读取序列中的每个元素
for (int i = 0; i < n; i++) {
cin >> a[i];
}
// 动态规划求解最长上升子序列长度
for (int i = 1; i < n; i++) {
for (int j = 0; j < i; j++) {
// 如果前面的元素小于当前元素
if (a[j] < a[i]) {
// 更新以当前元素结尾的最长上升子序列长度
dp[i] = max(dp[i], dp[j] + 1);
}
}
}
int ans = 0;
// 遍历dp数组,找到最大值,即最长上升子序列长度
for (int i = 0; i < n; i++) {
ans = max(ans, dp[i]);
}
// 输出结果
cout << ans << endl;
return 0;
}
(三)真题 3:接龙序列
题目链接:https://www.lanqiao.cn/problems/19712/learning/
解题思路:
- 确定动态规划数组含义:使用
dp[i]
表示以数字i
结尾的最长接龙子序列的长度。这里i
取值范围是0 - 9
,代表整数个位可能出现的数字。 - 遍历输入数列:对于输入的每个整数(以字符串形式
s
读入),获取其首位数字x
和末位数字y
。根据接龙数列的定义,能和以x
结尾的接龙子序列连接上形成新的接龙子序列,所以更新dp[y]
,取dp[x] + 1
(即把当前数字接到以x
结尾的序列后 )和dp[y]
(之前以y
结尾的最长接龙子序列长度 )中的较大值。 - 记录最长接龙子序列长度:在更新
dp
数组的过程中,用变量m
记录所有dp[i]
中的最大值,这个m
就是整个数列中最长接龙子序列的长度。 - 计算删除数字个数:因为要求最少删除多少个数能使剩下序列是接龙序列,那么用数列的总长度
n
减去最长接龙子序列的长度m
,得到的就是最少需要删除的数字个数。
代码实现:
#include <bits/stdc++.h>
using namespace std;
// dp[i]表示以数字i结尾的最长接龙子序列的长度
int dp[10];
int main()
{
int n;
// 读取数列长度n
cin >> n;
string s;
int m = 0;
for (int i = 0; i < n; ++i) {
// 读取数列中的每个整数,以字符串形式读入
cin >> s;
// 获取整数首位数字,转换为数字类型
int x = s[0] - '0';
// 获取整数末位数字,转换为数字类型
int y = s[s.size() - 1] - '0';
// 更新dp[y],取dp[x]+1(接上当前数字)和dp[y](之前以y结尾的最长长度)的较大值
dp[y] = max(dp[x] + 1, dp[y]);
// 更新m为所有dp[i]中的最大值,即最长接龙子序列长度
m = max(m, dp[y]);
}
// 输出最少删除的数字个数,用数列总长度n减去最长接龙子序列长度m
cout << n - m << endl;
return 0;
}
六、总结
通过今天对递归、记忆化搜索和动态规划的学习,以及结合蓝桥杯真题的实战演练,相信大家对这几种算法思想有了更深刻的理解。递归是一种基础且重要的思想,记忆化搜索是对递归的优化,而动态规划则是解决复杂问题的有效手段。在蓝桥杯竞赛中,熟练运用这些算法,能帮助我们解决很多具有挑战性的题目。
后续我们还会继续讲解树、图及简单数论等知识,希望大家持续关注,不断提升自己的算法水平,在蓝桥杯竞赛中取得优异成绩!