当前位置: 首页 > news >正文

递归专题1 - 递归基础与思维方法

递归专题1 - 递归基础与思维方法

本文是递归算法系列的第1篇,完整体系包括:

  1. 递归基础与思维方法 (本文)
  2. 二叉树DFS专题
  3. 回溯算法十大类型
  4. 网格DFS与回溯
  5. FloodFill算法专题

在这里插入图片描述

📋 目录

  • 前言
  • 一、递归的本质
    • 1.1 递归是什么
    • 1.2 递归三要素
    • 1.3 递归思维的关键点
  • 二、递归函数设计五步法
    • Step 1: 明确函数语义
    • Step 2: 找递归出口
    • Step 3: 确定子问题
    • Step 4: 设计递归体
    • Step 5: 检查语义一致性
  • 三、递归的两种返回值
    • 3.1 void返回值
    • 3.2 bool/int返回值
    • 3.3 两种方式对比
  • 四、常见错误与避坑
  • 五、递归模板
  • 六、实战示例
  • 七、总结

前言

之前做递归题的时候,总是觉得思路很乱,不知道从哪里入手。有时候递归出口判断不对,有时候参数传递有问题,有时候又不知道该用什么返回值。做了十几道递归题之后,开始总结规律,发现递归其实有一套固定的思考方式和检查清单。这篇文章记录的就是这些经验。

这篇文章不讲高深的理论,只讲实际做题时怎么思考、怎么检查、怎么避免常见错误。


一、递归的本质

1.1 递归是什么

递归就是函数调用自己。但光这么说没什么用,关键是要理解递归的思考方式。

递归的核心:
1. 把大问题拆成小问题 (小问题和大问题是同类型的)
2. 小问题的答案已知,或者可以继续拆
3. 用小问题的答案拼出大问题的答案

举个例子,计算阶乘:

factorial(5) = 5 * factorial(4)
factorial(4) = 4 * factorial(3)
...
factorial(1) = 1  <- 这是最小问题

可以看到,factorial(n)factorial(n-1) 是同类型的问题,只是规模变小了。

1.2 递归三要素

写递归代码时,必须搞清楚三件事:

1. 递归出口 (Base Case)

// 什么时候不再递归?
if (到达边界) {return 最小问题的答案;
}

没有递归出口,程序会无限递归,最后栈溢出。

2. 递归体 (Recursive Case)

// 如何拆成小问题?
// 如何调用递归函数?

这是递归的核心逻辑。

3. 返回值

// 如何用小问题的答案构建大问题的答案?
return ...;

1.3 递归思维的关键点

刚开始写递归的时候,我总是想把整个递归过程在脑子里展开,结果越想越乱。后来发现,递归的正确思考方式是:

只考虑当前层做什么,相信递归会处理好子问题

举例:计算二叉树的最大深度

int maxDepth(TreeNode* root) {if (root == nullptr) return 0;  // 递归出口// 只需要考虑:// 1. 左子树的深度是多少? -> 相信递归会给我正确答案int leftDepth = maxDepth(root->left);// 2. 右子树的深度是多少? -> 相信递归会给我正确答案  int rightDepth = maxDepth(root->right);// 3. 当前树的深度 = max(左, 右) + 1return max(leftDepth, rightDepth) + 1;
}

关键是不要去想"maxDepth(root->left) 内部是怎么运行的",只需要知道它会返回左子树的深度。


二、递归函数设计五步法

做了很多递归题后,总结出了一个固定的步骤。每次写递归函数,都按这五步走,可以避免很多错误。

Step 1: 明确函数语义

第一步是给递归函数下个定义。这个定义要清晰、明确。

好的定义:
- fibonacci(n): 返回第n个斐波那契数
- maxDepth(root): 返回以root为根的树的最大深度
- dfs(grid, i, j): 从(i,j)出发遍历连通区域不好的定义:
- helper(...): 干什么的? 不知道
- solve(...): 解决什么? 不清楚

函数名和参数都要能体现出函数的作用。

Step 2: 找递归出口

问自己:什么时候不需要再递归了?

常见的递归出口:

// 1. 空指针
if (root == nullptr) return ...;// 2. 越界
if (i < 0 || i >= m || j < 0 || j >= n) return ...;// 3. 最小规模
if (n == 0 || n == 1) return ...;// 4. 已访问
if (vis[i][j]) return ...;

注意:

  • 递归出口要写在函数开头
  • 必须覆盖所有边界情况
  • 返回值要和函数语义一致

Step 3: 确定子问题

问自己:

  • 大问题怎么拆成小问题?
  • 小问题和大问题是同类型的吗?

常见的拆分方式:

1) 一分为二 (二叉树)

leftResult = func(root->left);
rightResult = func(root->right);

2) 规模减一 (数组/数值)

result = func(n - 1);
// 或
result = func(index + 1);

3) 多路分支 (回溯)

for (每个选择) {func(下一层);
}

Step 4: 设计递归体

问自己:如何用子问题的结果?

根据问题类型:

信息收集型 (收集子问题返回的信息)

int left = maxDepth(root->left);
int right = maxDepth(root->right);
return max(left, right) + 1;

结构修改型 (修改树/图的结构)

root->left = pruneTree(root->left);
root->right = pruneTree(root->right);
if (满足删除条件) return nullptr;
return root;

路径搜索型 (回溯,需要恢复现场)

path.push_back(x);
dfs(...);
path.pop_back();  // 恢复现场

Step 5: 检查语义一致性

最后检查:

  • 所有return的值类型是否一致?
  • 参数传递是否正确?
  • 全局变量有没有忘记恢复?

一个常见错误:

// 错误示例
TreeNode* func(root) {if (root == nullptr) return nullptr;  // 返回指针if (某条件) return true;               // 返回bool? 类型不对!return root;
}

三、递归的两种返回值

做题时发现,递归函数的返回值主要有两种:voidbool/int。什么时候用哪种,之前一直搞不清楚。

3.1 void返回值

适用场景:

  • 遍历所有可能
  • 用全局变量记录结果
  • 不需要提前终止

示例:收集所有路径

vector<vector<int>> ret;  // 全局变量
vector<int> path;void dfs(TreeNode* root) {if (root == nullptr) return;if (是叶子节点) {ret.push_back(path);  // 记录到全局变量return;}path.push_back(root->val);dfs(root->left);dfs(root->right);path.pop_back();
}

关键是用全局变量记录结果。

3.2 bool/int返回值

适用场景:

  • 找到一个解就停止
  • 需要提前终止递归
  • 需要向上层传递信息

示例:判断路径和是否存在

bool hasPathSum(TreeNode* root, int targetSum) {if (root == nullptr) return false;if (是叶子 && 值等于targetSum) {return true;  // 找到了,返回true}// 只要左边或右边有一个true,就返回truereturn hasPathSum(root->left, targetSum - root->val) ||hasPathSum(root->right, targetSum - root->val);
}

用bool可以提前终止,不用遍历所有路径。

3.3 两种方式对比

特性void返回值bool/int返回值
适用场景遍历所有可能找一个解就停
结果记录全局变量返回值
能否提前终止不能
示例收集所有路径判断是否存在路径

判断技巧:

  • 题目要求"所有"、“遍历” -> 用void
  • 题目要求"是否存在"、“判断” -> 用bool

四、常见错误与避坑

4.1 忘记递归出口

// 错误
int sum(int n) {return n + sum(n - 1);  // 没有出口,无限递归
}// 正确
int sum(int n) {if (n == 0) return 0;  // 递归出口return n + sum(n - 1);
}

4.2 出口判断不全

// 错误:只判断了nullptr,没判断叶子节点
void getPath(TreeNode* root, vector<int>& path) {if (root == nullptr) return;path.push_back(root->val);getPath(root->left, path);   // 如果是叶子,left和right都是空getPath(root->right, path);  // 会把叶子加两次
}// 正确:加上叶子判断
void getPath(TreeNode* root, vector<int>& path) {if (root == nullptr) return;if (root->left == nullptr && root->right == nullptr) {path.push_back(root->val);// 记录路径return;}path.push_back(root->val);getPath(root->left, path);getPath(root->right, path);
}

4.3 忘记恢复现场 (回溯)

// 错误:只做选择,不撤销
void dfs() {path.push_back(x);dfs();// 忘记pop_back了
}// 正确:做选择后要恢复
void dfs() {path.push_back(x);dfs();path.pop_back();  // 必须恢复
}

4.4 参数传递错误

// 错误:传了引用,但想要的是副本
void dfs(vector<int>& path) {  // 引用if (...) {ret.push_back(path);  // 后面path变了,这里也会变}path.push_back(x);dfs(path);
}// 正确:该用副本就用副本
void dfs(vector<int> path) {  // 副本if (...) {ret.push_back(path);  // 保存副本,不受后面影响}path.push_back(x);dfs(path);
}

五、递归模板

把这些经验整理成两个模板,以后遇到递归题直接套用。

模板1: 信息收集型 (二叉树常用)

ReturnType function(TreeNode* root) {// 1. 递归出口if (root == nullptr) return 初始值;// 2. 递归获取子树信息ReturnType left = function(root->left);ReturnType right = function(root->right);// 3. 利用子树信息ReturnType result = combine(left, right, root->val);return result;
}

适用题目:

  • 二叉树最大深度
  • 二叉树直径
  • 平衡二叉树判断

模板2: 回溯搜索型

void backtrack(参数) {// 1. 递归出口if (满足条件) {记录结果;return;}// 2. 剪枝if (不满足约束) return;// 3. 回溯框架for (遍历选择) {// 做选择修改状态;// 递归backtrack(下一层参数);// 撤销选择恢复状态;}
}

适用题目:

  • 全排列
  • 组合
  • 子集

六、实战示例

示例1: 斐波那契数列

问题: 计算第n个斐波那契数

分析:

  • 递归定义: fib(n) = fib(n-1) + fib(n-2)
  • 递归出口: n = 0n = 1
int fib(int n) {// Step 1: 递归出口if (n == 0) return 0;if (n == 1) return 1;// Step 2: 拆分子问题int f1 = fib(n - 1);int f2 = fib(n - 2);// Step 3: 合并结果return f1 + f2;
}

示例2: 二叉树最大深度

问题: 返回二叉树的最大深度

分析:

  • 递归定义: maxDepth(root) = max(左子树深度, 右子树深度) + 1
  • 递归出口: root == nullptr
int maxDepth(TreeNode* root) {// Step 1: 递归出口if (root == nullptr) return 0;// Step 2: 获取子树深度int leftDepth = maxDepth(root->left);int rightDepth = maxDepth(root->right);// Step 3: 计算当前树深度return max(leftDepth, rightDepth) + 1;
}

示例3: 全排列

问题: 给定数组,返回所有排列

分析:

  • 需要遍历所有可能 -> 用void + 回溯
  • 需要标记数组记录哪些元素已用
vector<vector<int>> ret;
vector<int> path;
bool check[10];void dfs(vector<int>& nums) {// Step 1: 递归出口if (path.size() == nums.size()) {ret.push_back(path);return;}// Step 2: 回溯框架for (int i = 0; i < nums.size(); i++) {if (check[i]) continue;  // 剪枝// 做选择path.push_back(nums[i]);check[i] = true;// 递归dfs(nums);// 撤销选择path.pop_back();check[i] = false;}
}

七、总结

这篇文章总结了递归的基础思维方法和常见模式。核心要点:

思维方法:

  1. 只考虑当前层,相信递归处理好子问题
  2. 不要展开递归过程

设计五步:

  1. 明确函数语义
  2. 找递归出口
  3. 确定子问题
  4. 设计递归体
  5. 检查一致性

返回值选择:

  • 遍历所有 -> void + 全局变量
  • 找一个就停 -> bool/int

常见错误:

  • 忘记递归出口
  • 出口判断不全
  • 忘记恢复现场
  • 参数传递错误

掌握这些基础后,后面的二叉树DFS、回溯算法就会容易很多。


系列文章

  1. 递归基础与思维方法 (本文)
  2. 二叉树DFS专题
  3. 回溯算法十大类型
  4. 网格DFS与回溯
  5. FloodFill算法专题
http://www.dtcms.com/a/548550.html

相关文章:

  • 黄金分割与对数螺线
  • Vue 数据绑定深入浅出:从 v-bind 到 v-model 的实战指南
  • python - day10
  • MySQL 中的 行锁(Record Lock) 和 间隙锁(Gap Lock)
  • 【Docker】P1 Docker 基础入门指南
  • 【OD刷题笔记】- API集群负载统计
  • 韩城市网站建设wordpress 手工网站
  • Java—常见API(String、ArrayList)
  • 【STM32项目开源】STM32单片机医疗点滴控制系统
  • 游戏类网站备案需要前置审批吗怎么制作图片表格
  • AWS EC2 服务器弹性伸缩:基于 CPU 使用率创建伸缩组,实现资源动态调整
  • srt服务器,推拉流
  • Rust API 设计中的零成本抽象原则:从原理到实践的平衡艺术
  • Work-Stealing 调度算法:Rust 异步运行时的核心引擎
  • 服务器恶意进程排查:从 top 命令定位到病毒文件删除的实战步骤
  • 【案例实战】初探鸿蒙开放能力:从好奇到实战的技术发现之旅
  • 服务器启动的时候就一个对外的端口,如何同时连接多个客户端?
  • LVS负载均衡集群理论详解
  • 三维重建【0-E】3D Gaussian Splatting:相机标定原理与步骤
  • Flutter---ListTile列表项组件
  • Spring Boot入门篇:快速搭建你的第一个Spring Boot应用
  • 《算法通关指南数据结构和算法篇(1)--- 顺序表相关算法题》
  • ReentrantLock 加锁与解锁流程详解(源码分析,小白易懂)
  • 鸿蒙Flutter三方库适配指南:06.插件适配原理
  • Linux 防火墙实战:用 firewalld 配置 External/Internal 区域,实现 NAT 内网共享上网
  • Java 学习29:方法
  • Kafka 全方位详细介绍:从架构原理到实践优化
  • Obsidian 入门教程(二)
  • [测试工具] 如何把离线的项目加入成为git项目的新分支
  • 让数据导入导出更智能:通用框架+验证+翻译的一站式解决方案