AIGC标识 【算法】【学习笔记】深度优先搜索

水起来了,所以本文是更多AI精修内容的文章。orz,不过思路还是笔者想的。

深度优先搜索(DFS)是一种沿着一条路径尽可能深入,再回退继续搜索的遍历方法,常用于树、图的遍历以及回溯问题

DFS 可以使用递归显式栈实现。递归实现时,本质上利用的是函数调用栈。

核心步骤

  1. 确定当前状态;
  2. 确定边界条件;
  3. 选择下一状态并继续 DFS;
  4. 如果存在重复访问,使用 visited记录是否已经被搜索过;
  5. 如果是回溯问题,递归结束后撤销选择、恢复现场;

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)\)
  • 并查集(Union-Find)
    • 时间复杂度:\(O(N + M \cdot \alpha(N))\)
      初始化消耗 \(O(N)\),处理 \(M\) 条边进行集合合并需要 \(O(M \cdot \alpha(N))\),其中 \(\alpha(N)\) 为反阿克曼函数,增长极其缓慢,可近似看作常数 \(O(1)\)
    • 空间复杂度:\(O(N)\)
      仅需要存储 parent 数组,无需显式建图开辟邻接表空间。

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)\)

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的值和连通数的和。公式如下:

\[\text{总周长} = 4k - 2 \times \text{重合内边数} = 4k - \sum N_i \]

直接使用双重 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 遍历当前连通分量时,统计出:

  1. 该分量包含的节点总数 \(k\)
  2. 该分量内所有节点的度数之和 \(\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 辅助整理。

posted @ 2026-08-23 19:48  SEHOD  阅读(9)  评论(0)    收藏  举报