蓝桥杯 C/C++5 天逆袭省一之第四天 —— 递归,记忆化搜索,动态规划

一、递归

(一)递归的概念

递归是一种在函数定义中调用自身的编程技巧。简单来说,就是一个函数在它的函数体内调用它自身。递归通常用于解决可以被分解为相同性质的子问题的情况,并且这些子问题规模更小,当子问题规模减小到一定程度时,会有一个明确的终止条件。

(二)递归的基本要素

  1. 递归终止条件:这是递归能够结束的关键。如果没有终止条件,函数会一直调用自身,导致栈溢出错误。例如,计算阶乘时,当 n 等于 0 或 1 时,阶乘值为 1 ,这就是终止条件。
  2. 递归调用:在函数内部调用自身,并且每次调用时问题的规模要有所减小。比如计算 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)

(一)动态规划的概念

动态规划是一种通过把原问题分解为相对简单的子问题,并保存子问题的解来避免重复计算,从而解决复杂问题的方法。它与记忆化搜索有相似之处,但更强调问题的状态定义和状态转移方程的推导。

(二)动态规划的基本步骤

  1. 定义状态:确定用什么来表示问题的状态,比如在背包问题中,状态可以是当前背包容量和已经考虑的物品数量。
  2. 推导状态转移方程:找出不同状态之间的关系,即如何从已知状态推导出未知状态。例如,在斐波那契数列中,状态转移方程为 dp[n] = dp[n - 1] + dp[n - 2] 。
  3. 确定初始条件:明确问题最开始的状态值,如斐波那契数列中 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/

解题思路

  1. 输入处理与初始化:首先读取用户数量 n 和积分差 k 。接着读取每个用户的积分,在读取过程中记录最大积分 mx ,并使用 count 数组统计每个积分出现的次数。count[i] 表示积分值为 i 的用户数量。
  2. 特殊情况处理(k = 0 时):当 k 为 0 时,只要积分不同就不会匹配,那么答案就是不同积分的数量。通过遍历 count 数组,统计不为 0 的元素个数,即为答案。
  3. 一般情况处理(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)的方法来求解最长上升子序列的长度。

  1. 定义状态:定义一个数组 dp ,其中 dp[i] 表示以原序列中第 i 个元素结尾的最长上升子序列的长度。初始时,每个元素自身可以构成一个长度为 1 的上升子序列,所以 dp[i] 都初始化为 1 。
  2. 状态转移方程:对于 i 位置的元素,要找到 0 到 i - 1 中所有满足 a[j] < a[i] ( a 是原序列 )的位置 j ,然后更新 dp[i] ,即 dp[i] = max(dp[i], dp[j] + 1) 。这是因为如果能找到比当前元素小的前面的元素,那么就可以把当前元素接到那个元素结尾的上升子序列后面,长度加 1 。
  3. 遍历求解:通过两层循环遍历原序列,外层循环枚举每个位置 i ,内层循环枚举 0 到 i - 1 的位置 j ,按照状态转移方程更新 dp 数组。
  4. 获取结果:遍历完整个 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/

解题思路

  1. 确定动态规划数组含义:使用 dp[i] 表示以数字 i 结尾的最长接龙子序列的长度。这里 i 取值范围是 0 - 9 ,代表整数个位可能出现的数字。
  2. 遍历输入数列:对于输入的每个整数(以字符串形式 s 读入),获取其首位数字 x 和末位数字 y 。根据接龙数列的定义,能和以 x 结尾的接龙子序列连接上形成新的接龙子序列,所以更新 dp[y] ,取 dp[x] + 1 (即把当前数字接到以 x 结尾的序列后 )和 dp[y] (之前以 y 结尾的最长接龙子序列长度 )中的较大值。
  3. 记录最长接龙子序列长度:在更新 dp 数组的过程中,用变量 m 记录所有 dp[i] 中的最大值,这个 m 就是整个数列中最长接龙子序列的长度。
  4. 计算删除数字个数:因为要求最少删除多少个数能使剩下序列是接龙序列,那么用数列的总长度 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;
}

六、总结

通过今天对递归、记忆化搜索和动态规划的学习,以及结合蓝桥杯真题的实战演练,相信大家对这几种算法思想有了更深刻的理解。递归是一种基础且重要的思想,记忆化搜索是对递归的优化,而动态规划则是解决复杂问题的有效手段。在蓝桥杯竞赛中,熟练运用这些算法,能帮助我们解决很多具有挑战性的题目。

后续我们还会继续讲解树、图及简单数论等知识,希望大家持续关注,不断提升自己的算法水平,在蓝桥杯竞赛中取得优异成绩!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值