一、 递归 (Recursion)
1. 核心原理
递归是一种函数通过调用自身来解决问题的方法。其核心是将一个大规模问题分解为与原问题结构相同、但规模更小的子问题来求解。
递归三要素:
- 定义 (Function Definition): 明确递归函数的输入、输出和功能。例如,
fib(n)
的功能是计算第n
个斐波那契数。 - 分解 (Decomposition): 将问题分解为子问题,并通过调用自身来解决。例如,
fib(n) = fib(n-1) + fib(n-2)
。 - 基准情况 (Base Case): 定义一个或多个终止条件,防止无限递归。例如,
fib(0) = 0
,fib(1) = 1
。
2. 经典问题与代码实现
问题1:斐波那契数
- 问题描述: 计算斐波那契数列的第 n 项。
- 技术点: 纯粹的递归分解,但存在大量的重复计算,导致时间复杂度为指数级 O(2n)。
- 优化思路 (记忆化搜索): 使用一个
memo
数组(备忘录)来存储已计算过的子问题的解。在计算前先检查备忘录,如果存在则直接返回,避免重复计算,将时间复杂度降至 O(n)。
优化后的C++实现 (记忆化搜索)
C++
#include <vector>
class Solution {
public:
int fib(int n) {
if (n < 2) {
return n;
}
// memo数组初始化为-1,表示未计算
std::vector<int> memo(n + 1, -1);
return dfs(n, memo);
}
private:
int dfs(int n, std::vector<int>& memo) {
// Base Case
if (n < 2) {
return n;
}
// 如果已经计算过,直接返回结果
if (memo[n] != -1) {
return memo[n];
}
// 计算并存入备忘录
memo[n] = dfs(n - 1, memo) + dfs(n - 2, memo);
return memo[n];
}
};
复杂度分析
- 时间复杂度 : O(n),因为每个子问题
dfs(i)
只被计算一次。 - 空间复杂度 : O(n),递归栈的深度和
memo
数组的空间。
问题2:汉诺塔
-
问题描述: 将 N 个盘子从源柱 A 借助辅助柱 B 移动到目标柱 C。
-
技术点:
经典的递归分治问题。
- 将
n-1
个盘子从 A 移动到 B (借助 C)。 - 将第
n
个盘子从 A 移动到 C。 - 将
n-1
个盘子从 B 移动到 C (借助 A)。
- 将
**C++ 实现 **
#include <vector>
class Solution {
public:
void hanota(std::vector<int>& A, std::vector<int>& B, std::vector<int>& C) {
dfs(A.size(), A, B, C);
}
private:
// 将 n 个盘子从 src 移动到 dest,借助 aux
void dfs(int n, std::vector<int>& src, std::vector<int>& aux, std::vector<int>& dest) {
// Base Case: 只有一个盘子,直接移动
if (n == 1) {
dest.push_back(src.back());
src.pop_back();
return;
}
// 1. 将 n-1 个盘子从 src 移到 aux
dfs(n - 1, src, dest, aux);
// 2. 将 src 剩下的最大盘子移到 dest
dest.push_back(src.back());
src.pop_back();
// 3. 将 n-1 个盘子从 aux 移到 dest
dfs(n - 1, aux, src, dest);
}
};
复杂度分析
- 时间复杂度: O(2n),移动步数递归关系为 T(n)=2T(n−1)+1。
- 空间复杂度: O(n),递归栈深度。
二、 搜索 (Depth-First Search, DFS)
1. 核心原理
深度优先搜索是一种用于遍历或搜索树或图的算法。它从根节点(或任意节点)开始,沿着一条路径尽可能深地探索,直到到达路径末端,然后回溯到上一个节点,继续探索其他未访问的分支。
2. 经典问题与代码实现
DFS 在二叉树问题中应用广泛,根据根节点的访问时机,分为前序、中序、后序遍历。
问题1:二叉树遍历与验证
- 问题描述: 验证一个二叉树是否为有效的二叉搜索树(BST)。
- 技术点: BST 的一个关键性质是其中序遍历结果是一个严格递增的序列。
- 实现: 对树进行中序遍历,同时记录前一个访问的节点值
prev
。在访问当前节点时,检查其值是否大于prev
。
C++ 实现
#include <climits>
struct TreeNode {
int val;
TreeNode *left;
TreeNode *right;
TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
};
class Solution {
private:
// 使用 long long 防止节点值为 INT_MIN 时出错
long long prev = LONG_MIN;
public:
bool isValidBST(TreeNode* root) {
if (root == nullptr) {
return true;
}
// 1. 遍历左子树
if (!isValidBST(root->left)) {
return false;
}
// 2. 访问当前节点
if (root->val <= prev) {
return false;
}
prev = root->val;
// 3. 遍历右子树
return isValidBST(root->right);
}
};
约束与边界
- 必须使用
long long
类型的prev
变量,因为节点的val
可能是INT_MIN
。如果prev
是int
,当root->val
等于INT_MIN
时,root->val <= prev
会判断错误。
问题2:路径求和
- 问题描述: 求二叉树中从根节点到叶子节点的所有路径所表示的数字之和。
- 技术点: 采用前序遍历(DFS),将父节点计算出的路径数值传递给子节点。
- 实现: 递归函数
dfs(node, currentSum)
,currentSum
表示从根到node
父节点的路径数值。
C++ 实现
struct TreeNode {
int val;
TreeNode *left;
TreeNode *right;
TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
};
class Solution {
public:
int sumNumbers(TreeNode* root) {
return dfs(root, 0);
}
private:
int dfs(TreeNode* root, int currentSum) {
if (root == nullptr) {
return 0;
}
currentSum = currentSum * 10 + root->val;
// 叶子节点,返回当前路径代表的数字
if (root->left == nullptr && root->right == nullptr) {
return currentSum;
}
// 非叶子节点,返回左右子树的和
return dfs(root->left, currentSum) + dfs(root->right, currentSum);
}
};
三、 回溯 (Backtracking)
1. 核心原理
回溯是一种通过试错 来解决问题的搜索算法,通常以 DFS 的形式实现。它在问题的解空间树(状态树)中进行搜索,当发现当前选择无法导向有效解时,就撤销选择 (回溯),退回到上一步,尝试其他选择。
回溯算法模板:
void backtrack(路径, 选择列表) {
if (满足结束条件) {
记录路径;
return;
}
for (选择 in 选择列表) {
做出选择;
backtrack(路径, 选择列表); // 递归
撤销选择; // 回溯
}
}
2. 经典问题与代码实现
问题1:全排列 (无重复元素)
- 问题描述: 给定一个不含重复数字的数组,返回其所有可能的全排列。
- 技术点: 典型回溯问题。使用一个
used
(或check
) 数组来标记哪些元素已被使用。 - 实现:
path
记录当前排列,used
标记元素使用情况。
**C++ 实现 **
#include <vector>
#include <algorithm>
class Solution {
public:
std::vector<std::vector<int>> permute(std::vector<int>& nums) {
std::vector<std::vector<int>> result;
std::vector<int> path;
std::vector<bool> used(nums.size(), false);
dfs(nums, path, used, result);
return result;
}
private:
void dfs(const std::vector<int>& nums, std::vector<int>& path,
std::vector<bool>& used, std::vector<std::vector<int>>& result) {
// 结束条件:路径长度等于数组长度
if (path.size() == nums.size()) {
result.push_back(path);
return;
}
for (int i = 0; i < nums.size(); ++i) {
if (used[i]) {
continue; // 跳过已使用的元素
}
// 做出选择
path.push_back(nums[i]);
used[i] = true;
// 递归
dfs(nums, path, used, result);
// 撤销选择 (回溯)
used[i] = false;
path.pop_back();
}
}
};
问题2:全排列 (含重复元素)
- 问题描述: 给定一个可能包含重复数字的数组,返回所有不重复的全排列。
- 技术点: 为避免重复,需要引入剪枝逻辑。
- 优化思路 (剪枝):
- 首先对原数组排序,使相同元素相邻。
- 在循环中,如果当前元素
nums[i]
与前一个元素nums[i-1]
相同,并且used[i-1]
为false
,则说明nums[i-1]
刚被回溯过。此时若选择nums[i]
,会产生与之前重复的排列。因此,跳过这种情况。
**C++ 实现 **
#include <vector>
#include <algorithm>
class Solution {
public:
std::vector<std::vector<int>> permuteUnique(std::vector<int>& nums) {
std::sort(nums.begin(), nums.end()); // 排序是剪枝的前提
std::vector<std::vector<int>> result;
std::vector<int> path;
std::vector<bool> used(nums.size(), false);
dfs(nums, path, used, result);
return result;
}
private:
void dfs(const std::vector<int>& nums, std::vector<int>& path,
std::vector<bool>& used, std::vector<std::vector<int>>& result) {
if (path.size() == nums.size()) {
result.push_back(path);
return;
}
for (int i = 0; i < nums.size(); ++i) {
if (used[i]) {
continue;
}
// 剪枝逻辑
if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) {
continue;
}
path.push_back(nums[i]);
used[i] = true;
dfs(nums, path, used, result);
used[i] = false;
path.pop_back();
}
}
};
问题3:N皇后问题
-
问题描述: 在 N×N 的棋盘上放置 N 个皇后,使其不能互相攻击。
-
技术点: 在二维平面上的回溯。需要判断列、主对角线和副对角线是否已被占用。
-
优化思路:
使用三个布尔数组
cols
,
diag1
,
diag2
来记录占用情况,实现 O(1)的冲突检查。
- 列:
cols[j]
- 主对角线 (左上到右下):同一条对角线上的点
(r, c)
,r-c
的值恒定。用diag1[r - c + n - 1]
记录 (加n-1
是为了将负数索引转为正数)。 - 副对角线 (右上到左下):同一条对角线上的点
(r, c)
,r+c
的值恒定。用diag2[r + c]
记录。
- 列:
**C++ 实现 **
#include <vector>
#include <string>
class Solution {
public:
std::vector<std::vector<std::string>> solveNQueens(int n) {
this->n = n;
path.assign(n, std::string(n, '.'));
cols.assign(n, false);
diag1.assign(2 * n - 1, false);
diag2.assign(2 * n - 1, false);
dfs(0);
return result;
}
private:
int n;
std::vector<std::vector<std::string>> result;
std::vector<std::string> path;
std::vector<bool> cols, diag1, diag2;
void dfs(int row) {
if (row == n) {
result.push_back(path);
return;
}
for (int col = 0; col < n; ++col) {
// 冲突检测
if (!cols[col] && !diag1[row - col + n - 1] && !diag2[row + col]) {
// 做出选择
path[row][col] = 'Q';
cols[col] = diag1[row - col + n - 1] = diag2[row + col] = true;
// 递归
dfs(row + 1);
// 撤销选择
cols[col] = diag1[row - col + n - 1] = diag2[row + col] = false;
path[row][col] = '.';
}
}
}
};
四、 FloodFill (泛洪填充)
1. 核心原理
FloodFill 是一种在多维数组(通常是二维矩阵,如图像)上,将与起始点相连通的、具有相同值的区域,替换为新值的算法。其本质是图的遍历(DFS或BFS)。
定义:
floodFill(image, sr, sc, newColor)
= 从
(sr, sc)
点开始,将所有与该点颜色相同且联通的像素点颜色替换为
newColor
-
实现 (DFS):
-
获取起始像素
(sr, sc)
的原始颜色originalColor
。 -
如果
originalColor
与newColor
相同,则无需操作,直接返回。 -
将
(sr, sc)
的颜色修改为newColor
。 -
向其上下左右四个方向的相邻像素递归调用
dfs
递归的条件是:
- 相邻像素坐标在矩阵边界内。
- 相邻像素的颜色等于
originalColor
。
-
-
约束: 算法的正确性依赖于先修改当前节点,再递归邻接节点的顺序,以避免因颜色未变而导致的无限递归。
2. 经典问题与代码实现
问题1:图像渲染 (LeetCode 733)
问题描述:
给定一个图像矩阵、一个起始像素坐标
(sr, sc)
和一个新颜色
newColor
,对起始像素所在区域进行颜色填充 3
。
- 技术点: 直接应用 FloodFill 的 DFS 实现。
C++ 实现 (源自文件)
C++
#include <vector>
class Solution {
private:
int m, n;
int originalColor;
int dx[4] = {0, 0, 1, -1};
int dy[4] = {1, -1, 0, 0};
public:
std::vector<std::vector<int>> floodFill(std::vector<std::vector<int>>& image, int sr, int sc, int newColor) {
originalColor = image[sr][sc];
// 如果起始颜色和新颜色相同,无需操作
if (originalColor == newColor) {
return image;
}
m = image.size();
n = image[0].size();
dfs(image, sr, sc, newColor);
return image;
}
private:
void dfs(std::vector<std::vector<int>>& image, int r, int c, int newColor) {
// 将当前像素颜色更新
image[r][c] = newColor;
// 向四个方向递归
for (int i = 0; i < 4; ++i) {
int new_r = r + dx[i];
int new_c = c + dy[i];
// 检查边界条件和颜色条件
if (new_r >= 0 && new_r < m && new_c >= 0 && new_c < n && image[new_r][new_c] == originalColor) {
dfs(image, new_r, new_c, newColor);
}
}
}
};
复杂度分析
- 时间复杂度: O(M×N),其中 M 和 N 是矩阵的行数和列数。每个像素点最多被访问一次。
- 空间复杂度: O(M×N),在最坏情况下(整个图像都是同一颜色),递归栈的深度可能达到 M×N。
问题2:岛屿数量 (LeetCode 200)
问题描述:
计算一个由 '1' (陆地) 和 '0' (水) 组成的二维网格中岛屿的数量 。
技术点:
遍历整个网格,每当遇到一个 '1' (陆地),就将岛屿计数加一,并以此陆地为起点执行 FloodFill (DFS),将整个岛屿(所有相连的 '1')"淹没"(例如,修改为 '0' 或其他标记),以防重复计数 。
- 实现: 主循环遍历网格,DFS函数负责淹没岛屿。
**C++ 实现 **
#include <vector>
class Solution {
public:
int numIslands(std::vector<std::vector<char>>& grid) {
if (grid.empty() || grid[0].empty()) {
return 0;
}
int m = grid.size();
int n = grid[0].size();
int count = 0;
for (int i = 0; i < m; ++i) {
for (int j = 0; j < n; ++j) {
if (grid[i][j] == '1') {
count++;
dfs(grid, i, j);
}
}
}
return count;
}
private:
void dfs(std::vector<std::vector<char>>& grid, int r, int c) {
int m = grid.size();
int n = grid[0].size();
// 边界检查或当前非陆地
if (r < 0 || r >= m || c < 0 || c >= n || grid[r][c] != '1') {
return;
}
// 淹没当前陆地
grid[r][c] = '0';
// 递归淹没邻接陆地
dfs(grid, r + 1, c);
dfs(grid, r - 1, c);
dfs(grid, r, c + 1);
dfs(grid, r, c - 1);
}
};
五、 记忆化搜索 (Memoization)
1. 核心原理
记忆化搜索是自顶向下 (Top-Down) 动态规划的一种实现形式。它本质上是带有备忘录 (memo) 的递归。通过缓存子问题的计算结果,来避免在递归过程中重复计算相同的子问题,从而极大地优化时间性能。
-
核心思想:
-
备忘录 (Memo): 通常使用数组或哈希表,存储
[子问题输入] -> [子问题解]
的映射。初始化为一个特殊值(如-1),表示该子问题尚未求解。
查询:
在递归函数的入口,首先检查备忘录中是否已存在当前子问题的解。如果存在,直接返回缓存的结果 。
计算与存储:如果备忘录中不存在,则正常进行递归计算。在计算出结果后,返回之前,将其存入备忘录 。
-
2. 经典问题与代码实现
问题1:不同路径 (LeetCode 62)
问题描述:
一个机器人在 m x n 网格的左上角,只能向右或向下移动,问到达右下角共有多少条不同路径 8
。
- 技术点: 一个典型的DP问题。
dp[i][j]
表示到达(i, j)
的路径数。状态转移方程为dp[i][j] = dp[i-1][j] + dp[i][j-1]
。用递归实现时,存在大量重叠子问题,适合记忆化搜索。
C++ 实现 (记忆化搜索)
C++
#include <vector>
class Solution {
public:
int uniquePaths(int m, int n) {
// memo[i][j] 存储到达 (i-1, j-1) 的路径数,初始化为-1
std::vector<std::vector<int>> memo(m, std::vector<int>(n, -1));
return dfs(m - 1, n - 1, memo);
}
private:
int dfs(int r, int c, std::vector<std::vector<int>>& memo) {
// Base Case: 起点只有一条路径
if (r == 0 && c == 0) {
return 1;
}
// 越界,无路径
if (r < 0 || c < 0) {
return 0;
}
// 查询备忘录
if (memo[r][c] != -1) {
return memo[r][c];
}
// 计算并存储
memo[r][c] = dfs(r - 1, c, memo) + dfs(r, c - 1, memo);
return memo[r][c];
}
};
问题2:矩阵中的最长递增路径 (LeetCode 329)
问题描述:
在一个整数矩阵中,找到从任意单元格出发的最长递增路径的长度。只能上下左右移动 。
-
技术点:
- 遍历矩阵中的每个单元格,将其作为起点,调用DFS计算从该点出发的最长递增路径。
- 最终结果是所有起点计算出的最大值。
- 如果不使用记忆化,
dfs(i, j)
会被重复计算多次。因此,memo[i][j]
用于存储从(i, j)
出发的最长递增路径长度。
C++ 实现 (记忆化搜索)
C++
#include <vector>
#include <algorithm>
class Solution {
private:
int m, n;
int dx[4] = {0, 0, 1, -1};
int dy[4] = {1, -1, 0, 0};
public:
int longestIncreasingPath(std::vector<std::vector<int>>& matrix) {
if (matrix.empty() || matrix[0].empty()) return 0;
m = matrix.size();
n = matrix[0].size();
std::vector<std::vector<int>> memo(m, std::vector<int>(n, 0)); // 0表示未计算
int max_len = 0;
for (int i = 0; i < m; ++i) {
for (int j = 0; j < n; ++j) {
max_len = std::max(max_len, dfs(matrix, i, j, memo));
}
}
return max_len;
}
private:
int dfs(const std::vector<std::vector<int>>& matrix, int r, int c, std::vector<std::vector<int>>& memo) {
// 如果已计算,直接返回
if (memo[r][c] != 0) {
return memo[r][c];
}
// 路径长度至少为1 (自身)
int current_max = 1;
for (int i = 0; i < 4; ++i) {
int new_r = r + dx[i];
int new_c = c + dy[i];
if (new_r >= 0 && new_r < m && new_c >= 0 && new_c < n && matrix[new_r][new_c] > matrix[r][c]) {
current_max = std::max(current_max, 1 + dfs(matrix, new_r, new_c, memo));
}
}
// 存入备忘录并返回
memo[r][c] = current_max;
return current_max;
}
};
边界与约束
- 在
longestIncreasingPath
中,备忘录memo
的初始值设为0,因为路径长度至少为1。这与通常使用-1作为未计算标记不同,但在此场景下是有效的区分。 - 记忆化搜索将暴力递归的指数级时间复杂度,优化为 子问题数量 × 计算单个子问题的时间。对于矩阵问题,通常是 O(M×N)。