递归专题1 - 递归基础与思维方法
递归专题1 - 递归基础与思维方法
本文是递归算法系列的第1篇,完整体系包括:
- 递归基础与思维方法 (本文)
- 二叉树DFS专题
- 回溯算法十大类型
- 网格DFS与回溯
- 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;
}
三、递归的两种返回值
做题时发现,递归函数的返回值主要有两种:void 和 bool/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 = 0或n = 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;}
}
七、总结
这篇文章总结了递归的基础思维方法和常见模式。核心要点:
思维方法:
- 只考虑当前层,相信递归处理好子问题
- 不要展开递归过程
设计五步:
- 明确函数语义
- 找递归出口
- 确定子问题
- 设计递归体
- 检查一致性
返回值选择:
- 遍历所有 -> void + 全局变量
- 找一个就停 -> bool/int
常见错误:
- 忘记递归出口
- 出口判断不全
- 忘记恢复现场
- 参数传递错误
掌握这些基础后,后面的二叉树DFS、回溯算法就会容易很多。
系列文章
- 递归基础与思维方法 (本文)
- 二叉树DFS专题
- 回溯算法十大类型
- 网格DFS与回溯
- FloodFill算法专题
