【算法】【学习笔记】深度优先搜索
水起来了,所以本文是更多AI精修内容的文章。orz,不过思路还是笔者想的。
深度优先搜索(DFS)是一种沿着一条路径尽可能深入,再回退继续搜索的遍历方法,常用于树、图的遍历以及回溯问题。
DFS 可以使用递归或显式栈实现。递归实现时,本质上利用的是函数调用栈。
核心步骤
- 确定当前状态;
- 确定边界条件;
- 选择下一状态并继续 DFS;
- 如果存在重复访问,使用
visited记录是否已经被搜索过; - 如果是回溯问题,递归结束后撤销选择、恢复现场;
DFS = 当前状态 → 边界判断 → 深入下一状态 → 返回
回溯 = DFS + 撤销选择
❔题目
1. 寻找图中是否存在路径
解题思路
问题简单来说就是,每个点没有重边,且没有自己连自己的边,从source到destination是否有路。
所以我们只用深度优先搜索,直到走到destination就是有路。
边界条件:
- 到达终点(成功):当当前节点 u === destination 时,直接返回 true。
- 重复访问/遇到环(失败):当当前节点 u 已经被访问过(visited[u] === true)时,说明走到了死胡同或回到了之前走过的节点,直接返回 false。
- 无路可走(失败):当遍历完节点 u 的所有邻居后,没有任何一条路能通向 destination,函数自然返回 false。
提示:这题可以用并查集,更快。只要起点和终点的父亲都是同一个,就说明有路。
并查集回顾:【数据结构】【学习笔记】并查集
实现
TypeScript和C#为深度优先搜索,C++为并查集。
function validPath(n: number, edges: number[][], source: number, destination: number): boolean {
if (source === destination) return true;
// 1. 建图(邻接表)
const graph: number[][] = Array.from({ length: n }, () => []);
for (const [u, v] of edges) {
graph[u].push(v);
graph[v].push(u);
}
const visited = new Array(n).fill(false);
// 2. DFS 递归函数
function dfs(u: number): boolean {
// 边界条件 1:到达终点
if (u === destination) return true;
// 标记当前节点已访问
visited[u] = true;
// 遍历所有邻居节点
for (const v of graph[u]) {
// 边界条件 2:只有未访问过的节点才继续探索
if (!visited[v]) {
if (dfs(v)) return true; // 只要有一条路能通,立刻返回 true
}
}
// 边界条件 3:所有邻居都试过了,无法到达终点
return false;
}
return dfs(source);
}
public class Solution {
public bool ValidPath(int n, int[][] edges, int source, int destination) {
var graph = new List<int>[n];
var visit = new bool[n];
for (int i = 0; i < n; i++) graph[i] = new List<int>();
foreach(var e in edges){
graph[e[0]].Add(e[1]);
graph[e[1]].Add(e[0]);
}
return dfs(source, destination, graph, visit);
}
private bool dfs(int u, int des, List<int>[] graph, bool[] visit){
// 边界条件1:到达终点
if(u == des) return true;
visit[u] = true;
foreach(var v in graph[u]){
// 边界条件2:只走没走过的路
if(!visit[v]){
if(dfs(v, des, graph, visit)) return true;
}
}
// 边界条件3:没有路可走
return false;
}
}
class UnionFind {
private:
vector<int> parent;
public:
UnionFind(int n){
parent.assign(n,0);
for(int i = 0; i < n; i++) parent[i] = i;
}
int find(int x){
if(parent[x] != x){
parent[x] = find(parent[x]);
}
return parent[x];
}
bool unite(int x, int y){
int rootX = find(x), rootY = find(y);
if(rootX == rootY) return false;
parent[rootY] = rootX;
return true;
}
};
class Solution {
public:
bool validPath(int n, vector<vector<int>>& edges, int source, int destination) {
UnionFind unio(n);
for(auto e : edges){
unio.unite(e[0], e[1]);
}
return unio.find(source) == unio.find(destination);
}
};
复杂度分析
- 深度优先搜索(DFS)
- 时间复杂度:\(O(N + M)\)
其中 \(N\) 为顶点数,\(M\) 为边数。建图与遍历图中的顶点与边均需线性时间。 - 空间复杂度:\(O(N + M)\)
邻接表存储所有节点与边需要 \(O(N + M)\),递归栈深度及 visited 数组最大消耗 \(O(N)\)。
- 时间复杂度:\(O(N + M)\)
- 并查集(Union-Find)
- 时间复杂度:\(O(N + M \cdot \alpha(N))\)
初始化消耗 \(O(N)\),处理 \(M\) 条边进行集合合并需要 \(O(M \cdot \alpha(N))\),其中 \(\alpha(N)\) 为反阿克曼函数,增长极其缓慢,可近似看作常数 \(O(1)\)。 - 空间复杂度:\(O(N)\)
仅需要存储 parent 数组,无需显式建图开辟邻接表空间。
- 时间复杂度:\(O(N + M \cdot \alpha(N))\)
2. 所有可能的路径
解题思路
使用深度优先搜索 + 回溯,首先要找到DFS的边界条件:
- 假如走到了
n-1,说明找到了路,把此时的路存入答案
回溯:每次行走时,走后要把走过的路pop出去,继续尝试其他路径。
使用 DFS + 回溯枚举从节点 0 到节点 n-1 的所有路径。
因为题目给出的图是 DAG(有向无环图),所以不需要额外的 visited 数组。
实现
function allPathsSourceTarget(graph: number[][]): number[][] {
// 先找到n
const n = graph.length;
const res: number[][] = [];
const road: number[] = [0];
function dfs(u: number){
if(u === n-1) {
res.push([...road]);
return;
}
for(const v of graph[u]){
road.push(v);
dfs(v);
road.pop();
}
}
dfs(0);
return res;
};
public class Solution {
public IList<IList<int>> AllPathsSourceTarget(int[][] graph) {
int n = graph.Length;
var res = new List<IList<int>>();
var road = new List<int> { 0 };
void Dfs(int u) {
if (u == n - 1) {
res.Add(new List<int>(road));
return;
}
foreach (int v in graph[u]) {
road.Add(v);
Dfs(v);
road.RemoveAt(road.Count - 1);
}
}
Dfs(0);
return res;
}
}
class Solution {
public:
vector<vector<int>> allPathsSourceTarget(vector<vector<int>>& graph) {
int n = graph.size();
vector<vector<int>> res;
vector<int> road = {0};
function<void(int)> dfs = [&](int u) {
if (u == n - 1) {
res.push_back(road);
return;
}
for (int v : graph[u]) {
road.push_back(v);
dfs(v);
road.pop_back();
}
};
dfs(0);
return res;
}
};
复杂度分析
-
时间复杂度:\(O(N \times 2^N)\)
最坏情况下从 \(0\) 到 \(n-1\) 共有 \(2^{n-2}\) 条路径,每条路径的平均长度为 \(O(N)\),复制路径到结果集中需要消耗 \(O(N)\) 时间。 -
空间复杂度:
- 辅助空间:\(O(N)\),包括 DFS 递归栈和
road。 - 返回结果空间:\(O(N \times 2^N)\),需要保存所有路径。
- 如果计算返回结果占用的空间,则总空间复杂度为 \(O(N \times 2^N)\)。
- 辅助空间:\(O(N)\),包括 DFS 递归栈和
3. 两个城市间路径的最小分数
解题思路
一开始想的是DFS+回溯+visited数组,但是这是落入了陷阱。
要注意,题目补充说明中写道:“一条路径可以多次包含同一条道路,也可以多次到达城市 1 和城市 n”。这意味着只要一条边属于城市 1 所在的同一个连通块,我们就可以从城市 1 出发,走这条边无数次,然后再走到城市 n。
因此,题目要求的“所有路径的最小分数”,本质上等价于:求包含城市 1 的整个连通块中,所有边权(距离)的最小值。
所以这里不需要任何回溯操作。只需要做一次普通的DFS遍历整个连通块,并记录遍历过程中遇到的最小边权即可。
实现
function minScore(n: number, roads: number[][]): number {
// 1. 建图(邻接表:节点编号从 1 到 n)
const graph: [number, number][][] = Array.from({ length: n + 1 }, () => []);
for (const [u, v, w] of roads) {
graph[u].push([v, w]);
graph[v].push([u, w]);
}
const visited = new Array(n + 1).fill(false);
let minScore = Infinity;
// 2. 普通 DFS:遍历节点 1 所在的整个连通块
function dfs(u: number) {
visited[u] = true;
for (const [v, w] of graph[u]) {
// 只要是连通块里的边,都可以刷新最小值
minScore = Math.min(minScore, w);
// 没访问过的邻居继续深入,访问过的不再重复递归(无需回溯)
if (!visited[v]) {
dfs(v);
}
}
}
dfs(1);
return minScore;
}
public class Solution {
public int MinScore(int n, int[][] roads) {
var graph = new List<(int to, int weight)>[n + 1];
for (int i = 1; i <= n; i++) {
graph[i] = new List<(int, int)>();
}
foreach (var r in roads) {
graph[r[0]].Add((r[1], r[2]));
graph[r[1]].Add((r[0], r[2]));
}
bool[] visited = new bool[n + 1];
int minScore = int.MaxValue;
void Dfs(int u) {
visited[u] = true;
foreach (var (v, w) in graph[u]) {
minScore = Math.Min(minScore, w);
if (!visited[v]) {
Dfs(v);
}
}
}
Dfs(1);
return minScore;
}
}
class Solution {
public:
int minScore(int n, vector<vector<int>>& roads) {
vector<vector<pair<int, int>>> graph(n + 1);
for (const auto& r : roads) {
graph[r[0]].push_back({r[1], r[2]});
graph[r[1]].push_back({r[0], r[2]});
}
vector<bool> visited(n + 1, false);
int minScore = INT_MAX;
auto dfs = [&](auto& self, int u) -> void {
visited[u] = true;
for (const auto& [v, w] : graph[u]) {
minScore = min(minScore, w);
if (!visited[v]) {
self(self, v);
}
}
};
dfs(dfs, 1);
return minScore;
}
};
复杂度分析
- 时间复杂度: \(O(N + M)\)
其中 \(N\) 为城市数,\(M\) 为道路数。算法只需遍历城市 1 所在的连通块中的每个节点和每条边一次。 - 空间复杂度: \(O(N + M)\)
主要用于储存邻接表、visited 数组以及递归调用栈。
4. 岛屿的周长
解题思路
由题可以看出,周长其实就是:当有k个点是岛屿,然后记录每个点的连通数,则周长=4k-(连通数之和)。那么目标就是寻找k的值和连通数的和。公式如下:
直接使用双重 for 循环遍历整个网格即可:
遇到 1 时,直接给周长加 4。
从(0,0)开始,检查其上下左右是否有 1,若有,岛屿点数加1,并且统计邻居数。
最后运用公式即可计算得出。
实现
function islandPerimeter(grid: number[][]): number {
const rows = grid.length;
const cols = grid[0].length;
let landCount = 0; // k
let neighborSum = 0; // 连通数之和
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (grid[r][c] === 1) {
landCount++;
// 检查四周邻居
if (r > 0 && grid[r - 1][c] === 1) neighborSum++; // 上
if (r < rows - 1 && grid[r + 1][c] === 1) neighborSum++; // 下
if (c > 0 && grid[r][c - 1] === 1) neighborSum++; // 左
if (c < cols - 1 && grid[r][c + 1] === 1) neighborSum++; // 右
}
}
}
return 4 * landCount - neighborSum;
}
public class Solution {
public int IslandPerimeter(int[][] grid) {
int rows = grid.Length;
int cols = grid[0].Length;
int landCount = 0;
int neighborSum = 0;
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
if (grid[r][c] == 1) {
landCount++;
if (r > 0 && grid[r - 1][c] == 1) neighborSum++;
if (r < rows - 1 && grid[r + 1][c] == 1) neighborSum++;
if (c > 0 && grid[r][c - 1] == 1) neighborSum++;
if (c < cols - 1 && grid[r][c + 1] == 1) neighborSum++;
}
}
}
return 4 * landCount - neighborSum;
}
}
class Solution {
public:
int islandPerimeter(vector<vector<int>>& grid) {
int rows = grid.size();
int cols = grid[0].size();
int landCount = 0;
int neighborSum = 0;
for (int r = 0; r < rows; ++r) {
for (int c = 0; c < cols; ++c) {
if (grid[r][c] == 1) {
landCount++;
if (r > 0 && grid[r - 1][c] == 1) neighborSum++;
if (r < rows - 1 && grid[r + 1][c] == 1) neighborSum++;
if (c > 0 && grid[r][c - 1] == 1) neighborSum++;
if (c < cols - 1 && grid[r][c + 1] == 1) neighborSum++;
}
}
}
return 4 * landCount - neighborSum;
}
};
复杂度分析
- 时间复杂度: \(O(R \times C)\)
其中 \(R\) 和 \(C\) 分别为网格的行数和列数,仅需遍历一次矩阵。 - 空间复杂度: \(O(1)\)
不需要递归栈,也不需要额外的标记数组。
5. 统计完全连通分量的数量
解题思路
首先,完全连通分量是指:一个连通分量,其内部的顶点之间两两相连,即该分量是完全图。
其中,连通分量是指:在无向图中,极大连通子图称为连通分量。
初始思路:
寻找完全连通分量,也就是此分量中,每一个点都与对方有边。
特判:
- 完全无边的一点为一个完全连通分量
- 两个点都只互相连接,没有其他的边。
从第三个点开始就不一样了,所以只要对每个点进行深度搜索,假如搜索中的子点,与前面的点无边,则可以离开这个分量了,这个分量必定不是完全连通分量。
不过在写代码时,如果每次都去逐个校验子点与前面的点是否有边,逻辑会比较繁琐。
此时可以利用图论中的度数定理将条件极大简化:
对于一个包含 \(k\) 个节点的连通分量,它是完全连通分量的充要条件是:该分量内每个节点的度数(邻居数量)都必须等于 \(k - 1\)。
换句话说,只要在 DFS 遍历当前连通分量时,统计出:
- 该分量包含的节点总数 \(k\)
- 该分量内所有节点的度数之和 \(\text{totalDegree}\)
只要满足 \(\text{totalDegree} == k \times (k - 1)\),它就一定是一个完全连通分量(此公式对孤立点 \(k=1\) 和双节点 \(k=2\) 同样适用)。
实现
function countCompleteComponents(n: number, edges: number[][]): number {
// 1. 建图(邻接表)
const graph: number[][] = Array.from({ length: n }, () => []);
for (const [u, v] of edges) {
graph[u].push(v);
graph[v].push(u);
}
const visited = new Array(n).fill(false);
let completeCount = 0;
for (let i = 0; i < n; i++) {
if (visited[i]) continue;
let nodeCount = 0;
let totalDegree = 0;
// DFS 统计当前连通分量的节点数与度数和
function dfs(u: number) {
visited[u] = true;
nodeCount++;
totalDegree += graph[u].length; // 累加当前节点的度数
for (const v of graph[u]) {
if (!visited[v]) {
dfs(v);
}
}
}
dfs(i);
// 判断充要条件:总度数等于 k * (k - 1)
if (totalDegree === nodeCount * (nodeCount - 1)) {
completeCount++;
}
}
return completeCount;
}
public class Solution {
public int CountCompleteComponents(int n, int[][] edges) {
var graph = new List<int>[n];
for (int i = 0; i < n; i++) graph[i] = new List<int>();
foreach (var e in edges) {
graph[e[0]].Add(e[1]);
graph[e[1]].Add(e[0]);
}
bool[] visited = new bool[n];
int completeCount = 0;
for (int i = 0; i < n; i++) {
if (visited[i]) continue;
int nodeCount = 0;
int totalDegree = 0;
void Dfs(int u) {
visited[u] = true;
nodeCount++;
totalDegree += graph[u].Count;
foreach (int v in graph[u]) {
if (!visited[v]) {
Dfs(v);
}
}
}
Dfs(i);
if (totalDegree == nodeCount * (nodeCount - 1)) {
completeCount++;
}
}
return completeCount;
}
}
class Solution {
private:
vector<bool> vis;
void dfs(const vector<vector<int>>& roads, int u, int& V, int& E){
vis[u] = true;
V++;
E += roads[u].size();
for(int v : roads[u]){
if(!vis[v]){
dfs(roads,v, V,E);
}
}
}
public:
int countCompleteComponents(int n, vector<vector<int>>& edges) {
vis.assign(n, false);
// 记录所有路查询
vector<vector<int>> roads(n);
for (auto &e : edges) {
int u = e[0];
int v = e[1];
roads[u].push_back(v);
roads[v].push_back(u);
}
int ans = 0, V, E;
for (int i = 0; i < n; i++) {
if (!vis[i]) {
V = 0;
E = 0;
dfs(roads,i,V,E);
ans += E == V * (V - 1);
}
}
return ans;
}
};
复杂度分析
- 时间复杂度: \(O(V + E)\)
其中 \(V = n\) 为节点数,\(E\) 为边数。只需遍历一次整张图的所有顶点和边。 - 空间复杂度: \(O(V + E)\)
邻接表存储图需要 \(O(V + E)\) 空间,visited 数组和递归栈消耗 \(O(V)\) 空间。
引用
[1] 力扣探索模式
注:本文为个人学习与刷题笔记,部分文本结构与排版格式由 AI 辅助整理。

浙公网安备 33010602011771号