递归,回溯,DFS,Floodfill,记忆化搜索
一、 递归 (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 Caseif (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,借助 auxvoid 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 移到 auxdfs(n - 1, src, dest, aux);// 2. 将 src 剩下的最大盘子移到 destdest.push_back(src.back());src.pop_back();// 3. 将 n-1 个盘子从 aux 移到 destdfs(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) 的路径数,初始化为-1std::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)。