1. 深度优先搜索算法精讲
深度优先搜索(DFS)是算法竞赛和面试中最常考察的基础算法之一。这种"一条路走到黑"的搜索策略,配合回溯思想,能够高效解决排列组合、树形结构遍历、图连通性判断等经典问题。今天我们就通过9个典型例题,彻底掌握DFS的核心思想和实现技巧。
1.1 DFS的核心思想与框架
DFS算法的本质是递归+回溯。其核心思想是:从起点出发,沿着一条路径尽可能深入探索,直到无法继续前进时回退到上一个分叉点,选择另一条路径继续探索。这种"深度优先"的特性,使得DFS特别适合解决需要穷举所有可能性的问题。
DFS的通用代码框架如下(以C++为例):
cpp复制void dfs(当前状态){
if(到达终止条件){
处理结果;
return;
}
for(所有可能的扩展路径){
if(该路径可行){
标记选择;
dfs(下一状态);
撤销标记; // 回溯
}
}
}
这个框架包含了DFS的三个关键要素:
- 终止条件:确定递归何时结束
- 路径选择:确定下一步如何走
- 回溯处理:撤销当前选择,尝试其他可能性
1.2 DFS的时间复杂度分析
DFS的时间复杂度主要取决于两个因素:
- 状态空间的大小(即所有可能的路径数量)
- 每个状态的处理时间
对于排列问题,n个元素的全排列时间复杂度为O(n!)。对于二叉树遍历,时间复杂度通常为O(2^n)。在实际编码中,我们常通过剪枝(提前终止不可能产生最优解的路径)来优化效率。
2. 排列组合类问题实战
2.1 排列序数问题
问题描述:给定一个由小写字母组成的字符串,计算它在所有字母全排列中的字典序排名。
cpp复制#include <iostream>
#include <string>
using namespace std;
string s;
int res;
int n;
int dis[11]; // 存储目标字符串的字母序数
int a[11]; // 存储当前排列
bool vis[11];// 标记字母是否使用过
void dfs(int x) {
if (x == n) { // 完成一个排列
for (int i = 0; i < n; i++) {
if (a[i] < dis[i]) {
res++; // 找到一个字典序更小的排列
return;
}
else if (a[i] > dis[i]) {
return; // 后续排列字典序更大,无需处理
}
}
cout << res; // 找到目标排列,输出结果
exit(0);
}
for (int i = 0; i < n; i++) {
if (!vis[i]) {
vis[i] = true;
a[x] = i;
dfs(x + 1);
a[x] = 0; // 回溯
vis[i] = false;
}
}
}
int main() {
cin >> s;
n = s.size();
for (int i = 0; i < n; i++) {
dis[i] = s[i] - 'a'; // 将字母转换为0-25的数字
}
dfs(0);
return 0;
}
关键点说明:
- 将字母转换为数字便于比较(a→0,b→1,...,z→25)
- 按字典序生成所有排列,统计比目标排列小的数量
- 找到目标排列后立即退出程序(exit(0))
注意:当字符串有重复字母时,这种方法会重复计算排列。实际应用中应先统计各字母出现次数,使用公式计算不重复排列数。
2.2 十位数宝藏问题
问题描述:用数字0-9各一次组成一个10位数,找出能被11整除的最大数和最小数,计算它们的差。
cpp复制#include <iostream>
#include <algorithm>
using namespace std;
#define int long long
int a[11]; // 当前排列
bool vis[11]; // 标记数字是否使用过
int res1 = 0; // 最大满足条件的数
int res2 = 1e12; // 最小满足条件的数
int n = 10; // 10个数字
void dfs(int x) {
if (x == n) {
if (a[0] == 0) return; // 首位不能为0
int sum = 0;
for (int i = 0; i < n; i++) {
sum = sum * 10 + a[i];
}
if (sum % 11 == 0) {
res1 = max(res1, sum);
res2 = min(res2, sum);
}
return;
}
for (int i = 0; i < n; i++) {
if (!vis[i]) {
vis[i] = true;
a[x] = i;
dfs(x + 1);
a[x] = 0;
vis[i] = false;
}
}
}
signed main() {
dfs(0);
cout << res1 - res2;
return 0;
}
优化思路:
- 使用long long防止溢出
- 首位不能为0的剪枝
- 同时维护最大值和最小值,减少重复计算
2.3 带分数问题
问题描述:给定正整数n,计算有多少种形如a + b/c的表示方式,其中a、b、c恰好使用1-9各一次。
cpp复制#include <iostream>
using namespace std;
int a[10]; // 存储1-9的排列
bool vis[10]; // 标记数字是否使用过
int res;
int n = 9; // 使用1-9的数字
int num; // 输入的n
void dfs(int x) {
if (x == n) {
// 将排列分成a、b、c三部分
for (int i = 0; i < 7; i++) { // a最多7位数
for (int j = i + 1; j < 8; j++) { // b至少1位数
int A = 0, B = 0, C = 0;
// 计算a的值
for (int k = 0; k <= i; k++) {
A = A * 10 + a[k];
}
if (A >= num) continue; // 剪枝:a已经≥n
// 计算b的值
for (int k = i + 1; k <= j; k++) {
B = B * 10 + a[k];
}
// 计算c的值
for (int k = j + 1; k < 9; k++) {
C = C * 10 + a[k];
}
if (B % C == 0 && A + B / C == num) {
res++;
}
}
}
return;
}
for (int i = 1; i <= 9; i++) { // 注意从1开始
if (!vis[i]) {
vis[i] = true;
a[x] = i;
dfs(x + 1);
a[x] = 0;
vis[i] = false;
}
}
}
int main() {
cin >> num;
dfs(0);
cout << res;
return 0;
}
关键技巧:
- 通过双重循环枚举a、b、c的分割点
- 提前剪枝(A ≥ num时跳过)
- 确保b能被c整除(B % C == 0)
3. 二叉树相关问题解析
3.1 二叉树的最大深度
问题描述:计算二叉树的最大深度(根节点到最远叶子节点的路径长度)。
cpp复制class Solution {
public:
int maxDepth(TreeNode* root) {
if(root == nullptr) return 0;
int left = maxDepth(root->left);
int right = maxDepth(root->right);
return max(left, right) + 1;
}
};
算法分析:
- 基准情况:空树深度为0
- 递归计算左右子树深度
- 当前树深度 = max(左深度, 右深度) + 1
3.2 翻转二叉树
问题描述:将二叉树的左右子树完全翻转。
cpp复制class Solution {
public:
TreeNode* invertTree(TreeNode* root) {
if(root){
swap(root->left, root->right); // 交换左右子树
invertTree(root->left); // 递归翻转左子树
invertTree(root->right); // 递归翻转右子树
}
return root;
}
};
注意事项:
- 先交换再递归,保证子树已经交换
- 可以改写为迭代形式(使用栈模拟递归)
3.3 二叉搜索树的范围和
问题描述:计算二叉搜索树中所有值在[low, high]范围内的节点值之和。
cpp复制class Solution {
public:
int rangeSumBST(TreeNode* root, int low, int high) {
if(root == nullptr) return 0;
int res = 0;
if(root->val >= low && root->val <= high)
res += root->val;
res += rangeSumBST(root->left, low, high);
res += rangeSumBST(root->right, low, high);
return res;
}
};
优化思路(利用BST性质):
cpp复制int rangeSumBST(TreeNode* root, int low, int high) {
if(!root) return 0;
if(root->val < low)
return rangeSumBST(root->right, low, high);
if(root->val > high)
return rangeSumBST(root->left, low, high);
return root->val + rangeSumBST(root->left, low, high)
+ rangeSumBST(root->right, low, high);
}
3.4 开幕式焰火(统计不同颜色数量)
问题描述:统计二叉树中所有节点值的不同取值数量。
cpp复制class Solution {
unordered_map<int, int> map;
public:
int numColor(TreeNode* root) {
if (root == nullptr) return 0;
map[root->val]++;
numColor(root->left);
numColor(root->right);
return map.size();
}
};
优化技巧:
- 使用哈希表记录颜色出现情况
- 前序遍历、中序遍历或后序遍历均可
3.5 相同的树
问题描述:判断两棵二叉树是否完全相同。
cpp复制class Solution {
public:
bool isSameTree(TreeNode* p, TreeNode* q) {
if(p == nullptr && q == nullptr){
return true;
}
else if(p == nullptr || q == nullptr){
return false; // 一个为空一个不为空
}
if(p->val != q->val){
return false; // 值不相等
}
return isSameTree(p->left, q->left) &&
isSameTree(p->right, q->right);
}
};
边界情况处理:
- 两棵树都为空 → 相同
- 一棵空一棵不空 → 不同
- 节点值不同 → 不同
- 递归检查左右子树
4. DFS算法优化技巧
4.1 剪枝策略
剪枝是优化DFS效率的关键。常见剪枝方法包括:
- 可行性剪枝:当前路径明显不满足条件时提前返回
- 最优性剪枝:当前路径不可能优于已知最优解时提前返回
- 记忆化搜索:保存已计算过的状态结果,避免重复计算
4.2 迭代实现DFS
递归实现简洁但可能有栈溢出风险。DFS也可以用显式栈迭代实现:
cpp复制void dfs_iterative(TreeNode* root) {
if(!root) return;
stack<TreeNode*> s;
s.push(root);
while(!s.empty()) {
TreeNode* node = s.top();
s.pop();
// 处理当前节点
cout << node->val << " ";
// 注意压栈顺序:先右后左
if(node->right) s.push(node->right);
if(node->left) s.push(node->left);
}
}
4.3 避免重复状态
对于状态可能重复的问题(如网格DFS),需要记录已访问状态:
cpp复制void dfs_grid(int x, int y, vector<vector<bool>>& visited) {
if(x < 0 || x >= m || y < 0 || y >= n) return;
if(visited[x][y]) return;
visited[x][y] = true;
// 处理当前格子
// 四个方向探索
dfs_grid(x+1, y, visited);
dfs_grid(x-1, y, visited);
dfs_grid(x, y+1, visited);
dfs_grid(x, y-1, visited);
}
5. 常见错误与调试技巧
5.1 栈溢出问题
当递归深度过大时(如处理1e5节点的链表),会导致栈溢出。解决方法:
- 改用迭代实现
- 增大栈空间(编译选项)
- 优化算法减少递归深度
5.2 忘记回溯
在排列组合问题中,忘记撤销标记是常见错误:
cpp复制// 错误示例
void dfs(int x) {
// ...
for(int i=0; i<n; i++) {
if(!vis[i]) {
vis[i] = true;
dfs(x+1);
// 忘记 vis[i] = false;
}
}
}
5.3 终止条件错误
确保递归能够正常终止,避免无限递归:
- 明确基准情况
- 确保每次递归向基准情况靠近
5.4 状态表示不当
对于复杂状态,使用合适的数据结构表示:
- 小规模状态可用位压缩
- 复杂状态可用结构体或类封装
- 确保状态比较和哈希正确实现
6. 实战经验分享
在实际编程竞赛和面试中,DFS相关问题通常考察以下几个方面的能力:
- 递归思维:能否将问题分解为相似的子问题
- 边界处理:对各种特殊情况的考虑是否全面
- 剪枝优化:能否发现并利用问题特性减少计算量
- 状态设计:选择合适的方式表示问题状态
我个人的经验是:
- 先明确递归函数的定义(输入、输出、功能)
- 画递归树帮助理解问题结构
- 小规模测试验证边界条件
- 添加打印语句调试复杂递归
对于树形问题,掌握三种遍历方式的特点:
- 前序遍历:先处理根节点,适合自顶向下计算
- 中序遍历:二叉搜索树中得到有序序列
- 后序遍历:先处理子树,适合自底向上计算
最后提醒:DFS虽然思路直接,但在处理大规模数据时效率可能不足。实际应用中常需要结合记忆化(Memoization)或转为动态规划(DP)来优化。