【C/C++】递归 与 动态规划(DP)

- 第 82 篇 -
Date: 2025 - 03 - 17
2025 - 04 - 05 改
2025 - 04 - 07 改
2025 - 05 - 19 改
Author: 郑龙浩/仟墨
【递归与动态规划(DP) C/C++】

递归与动态规划

2025-04-07重新复习,并且又加了一些笔记,因为复习的时候发现有些地方又不明白了,而之前并没有在这个地方记笔记

一 递归

我看的课程,如下视频链接

https://www.bilibili.com/video/BV1LiS1YSEgF/?share_source=copy_web&vd_source=123565abb60ee9d849adaeb118d98b85

1基本介绍

是什么

递归就是函数自己调用自己。

核心

将大问题分解成规模更小的相同问题,直到达到可直接求解的简单情况(如 n=1),再逐层返回结果。
优化 - 记忆化搜索

如果过程中会出现重复计算,则可以提前将重复计算保存下来

2 递归技巧

(1) 递归三步法

  1. 定义基准条件(确定结束条件)

    明确递归终止的边界条件(如 阶乘时的n=1 ),避免无限递归

  2. 假设子问题已解决
    将递归函数视为黑盒,直接假设它能返回子问题的正确结果(例如:算 f(n) 时,直接相信 f(n-1)f(n-2) 是对的)

    说简单点就是: 只管向问问题,它一定能返回正确答案。

  3. 组合子问题的解(拆解问题)

    用子问题的解构建当前问题的解(如 return f(n-1) + f(n-2)

    说简单点就是: 把子问题的答案组合起来,得到当前问题的解

(2) 思维小技巧

无需提前理解完整递归过程!!!

我之前就陷入了这个问题。就是我在写递归的时候,必须想清楚递归从头到尾的所有过程和细节,其实没有必要,直接当作这个递归函数已经存在且写完

递归是「自相似性」的数学抽象,只需关注当前层逻辑,无需脑补调用栈细节(如 f(n-1) 内部如何实现)

不要陷入 “先有鸡还是先有蛋“ 的思维陷阱!!!

一定要注意,我之前因为陷入这个思维陷阱,特别较真,特别想搞清楚具体过程,太没必要了

  • 直接假设递归函数已能正确工作(即使它尚未写完),基于此设计当前逻辑。这种「信任递归」的思维是突破递归障碍的关键
  • 即直接将没写完的递归函数当作已经有了的函数去写,就认为这个函数是有答案的,这么去写就可以了,无需把这个递归函数中的自身的函数当作递归函数
  • 禁止:在编码时脑补多级递归调用的堆栈状态

3 例题

(1) 阶乘 (纯递归 or DP)

不使用记忆化搜索 Or 使用记忆化搜索

// 2025-03-16
#include <bits/stdc++.h>
using namespace std;
// 不使用记忆化搜索
long long funA( long long n ) {
    if (n == 1)
        return 1;
    return funA(n - 1) * n;
}
// 使用记忆化搜索
// 使用数组或哈希表记忆
unordered_map <long long, long long> memo;
long long funB( long long n ) {
    if (n == 1)
        return 1;
    if (memo.find(n) == memo.end()) {
        memo[n] = funB(n - 1) * n;
    }
    return memo[n];
}

int main( void ) {
    long long n;
    cin >> n;
    // 不使用记忆化搜索
    cout << "不使用记忆化搜索的答案:" << funA(n) << '\n';
    // 使用记忆化搜索
    cout << "使用记忆化搜索的答案:" << funB(n) << '\n';
    return 0;
}

(2) 斐波那契数列 (纯递归 or DP)

不使用记忆化搜索 Or 使用记忆化搜索

// 2025-03-16
#include <bits/stdc++.h>
using namespace std;
// 不使用记忆化搜索
long long funA( long long n ) {
    if (n <= 2) return 1;
    return funA(n - 1) + funA(n - 2);
}
// 使用记忆化搜索
// 使用数组或哈希表记忆
unordered_map <long long, long long> memo;
long long funB( long long n ) {
    if (n <= 2) return 1;
    if (memo.find(n) == memo.end())
        memo[n] = funB(n - 1) + funB(n - 2);
    return memo[n];
}

int main( void ) {
    // 第n位斐波那契数字
    long long n;
    cin >> n;
    // 不使用记忆化搜索
    cout << "不使用记忆化搜索的答案:" << funA(n) << '\n';
    // 使用记忆化搜索
    cout << "使用记忆化搜索的答案:  " << funB(n) << '\n';
    return 0;
}

(3) 汉诺塔 (纯递归 or DP)

必须要注意,打印的不是 前 n 个 圆盘从某个柱子移动到某个柱子,而是 第 n 个 圆盘从某个柱子移动到某个柱子。

我刚开始想了好久,就是这个地方理解错了,我以为打印的是 前 n 个 圆盘,后来意识到是 第 n 个 圆盘以后,恍然大悟,理解了这个程序。

然后为了助于我理解,我又写了一个中文打印过程版本,以免以后复习的时候,看到这个英文打印的代码又理解错了。

① 英文打印过程的版本

代码

// 2025-03-16
// 输入圆盘数量,三个柱子标识
// 打印圆盘的移动过程: 移动数量 from ? to ? ---> 错误理解
// 打印圆盘的移动过程: 第n个 from ? to ? (这个意思是第 n 个 从 ? 移动到 ?) --> 正确理解
// 注意:该题要求的是移动过程的输出,而不是真的移动,所以不要钻牛角尖,我想了半天为什么只打印不移动,实际人家要的结果就是打印
#include <bits/stdc++.h>
using namespace std;
void hanoi(int n, char F, char A, char T) {
    if (n == 1) { // 递归终止条件:只剩一个盘子时直接移动
        // 打印:move 1 from A to C
        // 打印:移动第 n 个盘子到目标柱 T
        printf("move %d from %c to %c\n", n, F, T);
        return;
    }
    // 步骤1:将前 n-1 个盘子从 F 移到 A(借助 T 辅助)
    hanoi(n - 1, F, T, A); 
    // 步骤2:打印:移动第 n 个盘子到目标柱 T
    printf("move %d from %c to %c\n", n, F, T);
    // 步骤3:将 n-1 个盘子从 A 移到 T(借助 F 辅助)
    hanoi(n - 1, A, F, T);
}
int main( void ) {
    int n, F, A, T;
    cout << "请输入圆盘数量" << endl;
    cin >> n;getchar();
    cout << "请输入起始柱、辅助柱、目标柱" << endl;
    scanf ("%c %c %c", &F, &A, &T);
    hanoi (n, F, A, T);
    return 0;
}

输入与运行结果

请输入圆盘数量
3
请输入起始柱、辅助柱、目标柱
A B C
move 1 from A to C
move 2 from A to B
move 1 from C to B
move 3 from A to C
move 1 from B to A
move 2 from B to C
move 1 from A to C
② 中文打印过程的版本(为了避免自己以后复习的时候又理解错误,故写了中文打印版本)

为了避免自己以后复习的时候又理解错误,故写了中文打印版本

代码

// 2025-03-16
// 输入圆盘数量,三个柱子标识
// 打印圆盘的移动过程: 移动数量 from ? to ? ---> 错误理解
// 打印圆盘的移动过程: 第n个 from ? to ? (这个意思是第 n 个 从 ? 移动到 ?) --> 正确理解
// 注意:该题要求的是移动过程的输出,而不是真的移动,所以不要钻牛角尖,我想了半天为什么只打印不移动,实际人家要的结果就是打印
#include <bits/stdc++.h>
using namespace std;
void hanoi(int n, char F, char A, char T) {
    if (n == 1) { // 递归终止条件:只剩一个盘子时直接移动
        // 打印:move 1 from A to C
        // 打印:移动第 n 个盘子到目标柱 T
        printf("将第 %d 个圆盘从 %c 柱子移动到 %c 柱子\n", n, F, T);
        return;
    }
    // 步骤1:将前 n-1 个盘子从 F 移到 A(借助 T 辅助)
    hanoi(n - 1, F, T, A); 
    // 步骤2:打印:移动第 n 个盘子到目标柱 T
    printf("将第 %d 个圆盘从 %c 柱子移动到 %c 柱子\n", n, F, T);
    // 步骤3:将 n-1 个盘子从 A 移到 T(借助 F 辅助)
    hanoi(n - 1, A, F, T);
}
int main( void ) {
    int n, F, A, T;
    cout << "请输入圆盘数量" << endl;
    cin >> n;getchar();
    cout << "请输入起始柱、辅助柱、目标柱" << endl;
    scanf ("%c %c %c", &F, &A, &T);
    hanoi (n, F, A, T);
    return 0;
}

输入与运行结果

请输入圆盘数量
3
请输入起始柱、辅助柱、目标柱
A B C
将第 1 个圆盘从 A 柱子移动到 C 柱子
将第 2 个圆盘从 A 柱子移动到 B 柱子      
将第 1 个圆盘从 C 柱子移动到 B 柱子      
将第 3 个圆盘从 A 柱子移动到 C 柱子      
将第 1 个圆盘从 B 柱子移动到 A 柱子      
将第 2 个圆盘从 B 柱子移动到 C 柱子      
将第 1 个圆盘从 A 柱子移动到 C 柱子

二 动态规划(DP)

我看的课程,如下视频链接

https://www.bilibili.com/video/BV1AB4y1w7eT/?share_source=copy_web&vd_source=123565abb60ee9d849adaeb118d98b85

之前接触递归的时候接触过动态规划中的一种,对递归进行优化的时候,使用了记忆化搜索,这里有一些DP的思想。

1 基本介绍

动态规划是一种使用空间换时间的算法,或者也叫做带备忘录的递归(有时DP不用递归),或者也叫做递归树的剪枝。

因为某些节点不需要进行重复计算

2 例题

题目

nums = [1, 5, 2, 4, 3]

子序列要求:从低到高

找出最长的子序列(返回最长序列的长度就行)

(1) 递归版动态规划(DP)

下列代码中我的疑惑点

我一直疑惑,为什么不是

max_len = find_max_len2(arr, j) + 1;

当然我当时以为直接存储从 j 开始往后的最大子序列长度然后加上 j 自己就行了(说白了这句话就是计算从 j 开始的最长子序列长度然后算上j自己)

但是我还忘记了另外多种情况,举例说明吧

下面第 i 次子序列的意思是:从第 i 个开始,往后的最长子序列是哪些个

然后将在当前元素后,比当前元素大的数量 + 当前1个元素个数 –> 得出从当前元素开始的最长子序列长度

  • 第 1 次子序列为: 5 长度为 1(在当前元素后,有多少个元素比当前元素大) + 1(当前的元素) –> 2
  • 第 2 次子序列为: 2, 4 长度为 2 + 1 –> 3
  • 第 3 次子序列为: 4 长度为 1 + 1 –> 2
  • 第 4 次子序列为: 3 长度为 1 + 1 –> 2

如果不写max(max_len, find_max_len2(arr, j) + 1)取最大长度的话,第 3 次 和第 4 次的长度会覆盖第 2 次的长度,所以还是要判断一下

其实这里的 max() 的作用和 if 作用相同, 如下:

t = find_max_len2(arr, j) + 1;
if (max_len < t)
    max_len = t
for (int j = i + 1; j < N; j++) {
    if (arr[j] > arr[i])
        max_len = max(max_len, find_max_len2(arr, j) + 1);
}

使用暴力枚举与递归 Or 使用动态规划(DP)与递归

写了两个函数,一个是用了DP,一个是没用DPz

// Date: 2025-03-16
// Author: 郑龙浩 / 仟濹
// DP + 递归 --> 优化版递归
// 记忆化搜索
// nums = [1, 5, 2, 4, 3]
// 子序列要求:从低到高
// 找出最长的子序列(返回最长序列的长度就行)
#include <bits/stdc++.h>
using namespace std;
#define N 5
int arr[N] = {1, 5, 2, 4, 3};
// 不使用记忆化搜索 / DP
// arr: 数组 i: 从第i个开始找最长的子序列长度
int find_max_len(int arr[], int i) {
    // 确定递归终止条件
    if (i == N - 1)
        return 1; // 第 N - 1 个元素就是最后一个元素,后边没有元素了,所以从第 N - 1 个元素开始找最长子序列的话,只有自己一个元素,最长子序列长度也就是 1 了
    int max_len = 1; // 存储最大长度 至少包含自己
    // 寻找下一个比i大的元素的位置,并且再次递归
    for (int j = i + 1; j < N; j++) {
        if (arr[j] > arr[i])
            // max_len: 当前已知的最长子序列长度
            // find_max_len(arr, j):  从 arr[j] 开始 的最长递增子序列长度(包含 arr[j])。
            // find_max_len(arr, j) + 1: 在以 arr[i] 开头的情况下,接上以 arr[j] 开头的最长子序列后的总长度(+1 就是加上 arr[i] 本身)
            max_len = max(max_len, find_max_len(arr, j) + 1);
    }
    return max_len;
}
// 使用记忆化搜索 / DP
// 使用哈希表 - 命名为 memo
unordered_map <int, int> memo;
int find_max_len2(int arr[], int i) {
    // 如果曾计算过 fin_max_len2(arr, i),那么memo会存储,则直接返回之前存储的结果即可
    // 只要 memo.find(i) != memo.end() 就表示在memo中,memo[i] 没有存储过结果
    if (memo.find(i) != memo.end())
        return memo[i];
    // 设置终止条件
    if (i == N - 1)
        return 1;
    int max_len = 1; // 最小也有自身
    for (int j = i + 1; j < N; j++) {
        if (arr[j] > arr[i])
            max_len = max(max_len, find_max_len2(arr, j) + 1);
    }
    memo[i] = max_len;
    return max_len;
}
// 不断调用find_max_len函数,将每个元素作为子序列的头个元素遍历
// 进而寻找众多子序列中最大的子序列长度
int len_max1(int arr[]) {
    int max_len = 1;
    for (int i = 0; i < N; i++) {
        int t = find_max_len(arr, i); // 减少计算次数,提前存储答案
        if (t > max_len) max_len = t;
    }
    return max_len;
}
int len_max2(int arr[]) {
    memo.clear(); // 清空记忆化存储
    int max_len = 1;
    for (int i = 0; i < N; i++) {
        int t = find_max_len2(arr, i); // 减少计算次数,提前存储答案
        if (t > max_len) max_len = t;
    }
    return max_len;
}
int main( void ){
    // 不使用DP/记忆化搜索
    int max1 = len_max1(arr);
    // 使用DP/记忆化搜索
    int max2 = len_max2(arr);
    cout << "纯递归的答案:" << max1 << '\n' << "DP的答案:" << max2 << '\n';
    return 0;
}

(2) 迭代版动态规划(DP)

这次就不适用递归了,用迭代版的DP时间复杂度更小

但是我感觉,迭代版DP递归版DP 难理解

因为递归版DP思路是正着想的,从前往后找最长子序列

但是迭代版DP思路是反着想的,从后往前找最长子序列,感觉不如从前往后好理解

而且还有一个很关键的语句 memo[i] = max(memo[i], memo[j] + 1);我理解了好久才明白什么意思 ! ! !

下面将每个地方拆分进行了解释:

  • memo[i]:arr[i] 开头的最长递增子序列的长度
  • memo[j]:arr[j] 开头的最长递增子序列的长度(j > i)

arr[i] < arr[j]时:

  • 可以选择将arr[i]与以arr[j]开头的子序列连接,
  • 这样新的子序列长度就是memo[j] + 1

我们需要在以下两者中取较大值:

  1. 当前memo[i]的值(不连接arr[j]的情况)
  2. memo[j] + 1(连接arr[j]后的新长度)

这样memo[i]始终保存以arr[i]开头的最长子序列长度

// Date: 2025-03-18
// 2025-04-07 增加注释--> 因为复习的时候发现memo[i] = max(memo[i], memo[j] + 1);不明白了
// 而且倒着从后往前找最长子序列我也不是很理解了,所以加了一些注释,便于自己理解
// 2025-05-19 复习的时候发现之前注释写错了,进行了修改
// Author: 郑龙浩 / 仟墨
// --P + 递归 --> 优化版递归
// 记忆化搜索
// nums = [1, 5, 2, 4, 3]
// 子序列要求:从低到高
// 找出最长的子序列(返回最长序列的长度就行)
#include <bits/stdc++.h>
using namespace std;
#define N 5
int arr[N] = {1, 5, 2, 4, 3};
// 使用迭代版 记忆化搜索 / DP
// 这次不用哈希表了,用vector - 命名为 memo
// 这次不用递归来DP了,而是用迭代来DP,这样时间复杂度就不是指数级别的了
vector <int> memo(N, 1);
int find_max_len() {
    int max_len = 1; // 最小也是1
    // 说白了就是从后向前遍历,计算以每个arr[i]作为第一个元素的最长递增子序列长度
    for (int i = N - 1; i >= 0; i--) {
        // 检查i后面的所有元素是否大于i的元素
        for (int j = i + 1; j < N; j++) {
            if (arr[i] < arr[j])
                // memo[i]: 以 arr[i] 开头的最长递增子序列的长度
                // memo[j]: 以 arr[j] 开头的最长递增子序列的长度(j > i)

                // 当arr[i] < arr[j]时:
                // 可以选择将arr[i]与以arr[j]开头的子序列连接,
                // 这样新的子序列长度就是memo[j] + 1

                // 我们需要在以下两者中取较大值:
                // 1. 当前memo[i]的值(不连接arr[j]的情况)
                // 2. memo[j] + 1(连接arr[j]后的新长度)
                // 这样memo[i]始终保存以arr[i]开头的最长子序列长度
                memo[i] = max(memo[i], memo[j] + 1); // 刷新以i为结尾的最长子序列长度
        }                                                                                                                                                                                                                                                                                                                                                             
    }
    return *max_element(memo.begin(), memo.end());
}

int main( void ){
    // 使用 迭代版DP
    int max = find_max_len();
    cout << "迭代版DP的答案:" << max << '\n';
    return 0;
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值