目录
正在加载目录…
专栏文章
专栏文章
经典算法专栏
1. 图算法:遍历、最短路、MST、拓扑与 Tarjan 2. 排序与搜索:十大排序、二分查找与动态规划 3. 动态规划:五类经典问题的状态转移方法 4. 业务场景算法:限流、Top-K 与一致性哈希 5. 算法复杂度:递归树、主定理与摊还分析 6. 基础数据结构:树、堆、哈希与并查集 7. 分治与贪心:拆解问题与局部最优策略 8. 字符串算法:KMP、BM、RK 与 Trie 9. 网络流与匹配:最大流、最小割与二分图 10. 二分算法:边界查找与答案二分模板 11. 位运算速查:二进制原理与高频解题模板 11. 并查集:连通性、路径压缩与进阶变体 12. 显式栈与状态机:替代递归的通用方法

图算法:遍历、最短路、MST、拓扑与 Tarjan

发布于 2026-06-10 07:48 · 最后编辑于 2026-08-17 15:08 · 字数 9,327 👁 314 次阅读

图由顶点和边组成,是描述"关系"最自然的数据结构。本文覆盖图的存储、BFS/DFS 搜索、Dijkstra/Bellman-Ford 最短路径、Prim/Kruskal 最小生成树和拓扑排序,每个算法配有过程图辅助理解。

目录

章节说明
图的基本概念顶点、边、有向/无向/带权图
图的存储邻接矩阵 vs 邻接表
BFS 广度优先搜索逐层扩散,保证最短路径
DFS 深度优先搜索沿路走到底,回溯换路
连通分量与并查集遍历整张图、合并组件与网格建模
强连通分量(SCC)有向图的双向可达与 Tarjan 模板
无向图的桥(关键连接)dfn/low 判定割边,LeetCode 1192
最短路径Dijkstra / Bellman-Ford
最小生成树Prim / Kruskal
拓扑排序有向无环图的线性排序
图题解题决策框架看到一道图题,如何判断用哪个算法

图的基本概念

图(Graph)顶点(Vertex)边(Edge) 组成,顶点可以与任意其他顶点建立连接关系。

概念说明示例
无向图边没有方向,A-B 等价于 B-A微信好友关系
有向图边有方向,A→B 不等于 B→A微博关注关系
带权图每条边有权重地图路程、网络延迟
度(Degree)与某顶点相连的边数无向图
入度(In-degree)指向该顶点的边数有向图
出度(Out-degree)从该顶点出发的边数有向图
稀疏图顶点多,边少(E ≪ V²)社交网络
稠密图边数接近 V²全连接网络

图的存储

同一张图可以用邻接矩阵或邻接表存储,二者的空间和时间取舍截然不同:

graph storage

邻接矩阵

用二维数组 A[V][V] 存储,A[i][j] 表示顶点 i 到顶点 j 的边权(无边时为 0 或 ∞):

int[][] graph = new int[V][V];
// 无向图:双向赋值
graph[i][j] = 1;
graph[j][i] = 1;
// 带权图:存权重
graph[i][j] = weight;
优点缺点
查询两点关系 O(1)空间 O(V²),稀疏图严重浪费
矩阵运算方便(Floyd-Warshall 等)无向图存储冗余(天然对称矩阵)

邻接表

每个顶点对应一个链表,只存储实际存在的邻居:

public class Graph {
    private final int v;
    private final LinkedList<Integer>[] adj;

    public Graph(int v) {
        this.v = v;
        adj = new LinkedList[v];
        for (int i = 0; i < v; i++) adj[i] = new LinkedList<>();
    }

    public void addEdge(int s, int t) { // 无向图
        adj[s].add(t);
        adj[t].add(s);
    }
}
优点缺点
空间 O(V+E),稀疏图节省查询两点关系需遍历链表 O(度数)
适合大规模稀疏图随机访问对 CPU 缓存不友好

升级版邻接表:将链表替换为红黑树、跳表或散列表,提升查询效率。如微博关注关系用跳表还能支持按用户名排序分页。

选型对比

维度邻接矩阵邻接表
空间复杂度O(V²)O(V+E)
查询两点关系O(1)O(度数)
遍历所有邻居O(V)O(度数)
适用场景稠密图、矩阵运算稀疏图(社交网络)

BFS 广度优先搜索

广度优先搜索(BFS) 用队列驱动,从起点向外逐层扩散,先访问距离为 1 的邻居,再访问距离为 2 的……

关键性质:BFS 找到的路径是边数最少的路径(无权图的最短路径)。

为什么逐层扩散就保证最短? BFS 按"距起点的边数"分层扩散:先访问距起点 1 条边的所有点,再 2 条边……当某节点第一次被访问时,它一定是从起点出发走"恰好 N 条边"首次到达,这个 N 就是它的最短边数。队列的 FIFO 保证"先被发现的点距离不会更大"——这是 BFS 能保证最短、而 DFS 不能的根本原因(DFS 一路深扎,第一次到达某点走的可能是条长绕路)。

识别信号:题目问"最少几步""最少操作次数""最短路径"且每步代价相同 → 优先 BFS。若每步代价不同(带权)则改用 Dijkstra。

graph bfs

实现

public void bfs(int s, int t) {
    if (s == t) return;
    boolean[] visited = new boolean[v];
    int[] prev = new int[v];         // prev[i] = 到达 i 的前驱节点
    Arrays.fill(prev, -1);

    Queue<Integer> queue = new LinkedList<>();
    visited[s] = true;
    queue.add(s);

    while (!queue.isEmpty()) {
        int w = queue.poll();
        for (int q : adj[w]) {
            if (!visited[q]) {
                prev[q] = w;
                if (q == t) {
                    printPath(prev, s, t); // 找到目标,打印路径
                    return;
                }
                visited[q] = true;
                queue.add(q);
            }
        }
    }
}

private void printPath(int[] prev, int s, int t) {
    if (prev[t] != -1 && t != s) printPath(prev, s, prev[t]);
    System.out.print(t + " ");
}

三个辅助变量的作用

变量作用
visited[]标记已访问,防止重复入队
queueFIFO 保证按层处理
prev[]反向记录路径,最后递归还原

复杂度

维度复杂度原因
时间O(V+E)每个顶点和每条边最多访问一次
空间O(V)visited、queue、prev 均为线性

DFS 深度优先搜索

深度优先搜索(DFS) 沿一条路走到底,走不通则回溯换路,本质上是递归调用栈的深度优先遍历。

关键性质:DFS 找到的路径不保证是最短路径,但能遍历图中所有可达顶点。

适用场景:DFS 的"走到尽头再回溯"特性,特别适合需要"探到底"的问题——连通性判断、环检测、拓扑排序、找割点/桥/SCC、回溯搜索所有方案。凡是要遍历整张图、不在意路径长度的,DFS 比 BFS 更省内存(只存当前路径栈,不像 BFS 要存整层)。

识别信号:问"是否连通/有环/能到达""所有方案""关键边或点"→ DFS;问"最少几步"→ BFS。

graph dfs

DFS 的边分类

DFS 走完一张图,所有边分四类(以有向图为完整框架):

边类型指向的节点与 u 的关系是否树边
树边(tree)u → 未访问的子节点
回边(back)u → u 的祖先
前向边(forward)u → u 的后代,但不是当初发现该后代的那条边
横叉边(cross)u → 既非祖先也非后代的已访问节点

树边 vs 前向边:都指向后代,区别在"是不是 DFS 用来发现该后代的那条边"。树边是 v 未访问时第一次发现 v 的边;前向边是 v 已访问后、又一条通向该后代的冗余边。例:有向边 A→BB→CA→C,DFS 从 A:A→B(树边)、B→C(树边)、A→C(C 已是后代 → 前向边)。前向边指向的后代 dfn 比自己大,对 low 无贡献,算法上常忽略。

横叉边:连接两个没有祖孙关系的已访问节点,只存在于有向图。例:有向边 A→BA→CC→B,DFS 从 A:A→B(树边)、A→C(树边)、C→B(B 在另一子树且已访问 → 横叉边)。

为什么无向图只有树边和回边? 无向边是双向的。当 DFS 在 u 发现邻居 v 已访问、且 v 不是 u 的父亲时,v 一定是 u 的祖先——若 v 是个已访问完的平行节点,v 当初被访问时沿 v-u 这条无向边早该走到 u、把 u 纳入 v 的子树;既然 u 在 v 之后才被访问,u 必是从 v 那条路下来的,v 是祖先。所以无向图没有前向边和横叉边。

这正是 Tarjan 在无向图成立的前提:边只有树边和回边两类,low 更新才简化成三条规则——遇到已访问邻居直接当回边处理。而有向图求 SCC 时横叉边会添乱,需用 inStack 把它排除。

实现

private boolean found = false;

public void dfs(int s, int t) {
    boolean[] visited = new boolean[v];
    int[] prev = new int[v];
    Arrays.fill(prev, -1);
    recurDfs(s, t, visited, prev);
    if (found) printPath(prev, s, t);
}

private void recurDfs(int w, int t, boolean[] visited, int[] prev) {
    if (found) return;
    visited[w] = true;
    if (w == t) { found = true; return; }
    for (int q : adj[w]) {
        if (!visited[q]) {
            prev[q] = w;
            recurDfs(q, t, visited, prev);
        }
    }
    // 此处隐式回溯:从 w 的所有邻居返回后,w 标记已访问但不影响其他路径
}

复杂度

维度复杂度
时间O(E)(每条边最多访问两次)
空间O(V)(visited 数组 + 递归调用栈)

BFS vs DFS 对比

维度BFSDFS
驱动结构队列(显式)调用栈(隐式递归)
路径保证最短路径(边数最少)不保证最短
内存占用较大(层宽可能很大)较小(只存当前路径)
典型应用最短路径、层序遍历、社交网络N度好友拓扑排序、连通分量、回溯搜索

连通分量与并查集

连通分量(Connected Component)是无向图中“任意两个点都能互相到达”的最大顶点集合。单次 BFS/DFS 只能覆盖一个起点可达的组件;遍历所有未访问顶点,每启动一次搜索,组件数加一。

graph connected components

用 DFS / BFS 计数

int countComponents(List<Integer>[] graph) {
    boolean[] visited = new boolean[graph.length];
    int components = 0;
    for (int i = 0; i < graph.length; i++) {
        if (!visited[i]) {          // 发现一个尚未覆盖的新组件
            components++;
            dfsMark(graph, i, visited);
        }
    }
    return components;
}

void dfsMark(List<Integer>[] graph, int u, boolean[] visited) {
    visited[u] = true;
    for (int v : graph[u]) {
        if (!visited[v]) dfsMark(graph, v, visited);
    }
}

时间复杂度为 O(V + E):每个顶点仅标记一次,每条边最多检查两次。BFS 只需把 dfsMark 换成队列版本,计数逻辑不变。

并查集:动态合并组件

当边持续加入、需要反复判断“两个点是否已连通”时,并查集比每次重新遍历更合适。count 初始为顶点数;一次成功的 union 将两个组件合并,count--

class UnionFind {
    private final int[] parent;
    private final int[] size;
    private int count;

    UnionFind(int n) {
        parent = new int[n];
        size = new int[n];
        count = n;
        for (int i = 0; i < n; i++) { parent[i] = i; size[i] = 1; }
    }

    int find(int x) {
        if (parent[x] != x) parent[x] = find(parent[x]); // 路径压缩
        return parent[x];
    }

    boolean union(int a, int b) {
        int ra = find(a), rb = find(b);
        if (ra == rb) return false;
        if (size[ra] < size[rb]) { int t = ra; ra = rb; rb = t; }
        parent[rb] = ra;
        size[ra] += size[rb];
        count--;
        return true;
    }

    int count() { return count; }
}

选择:静态图求一次组件数,用 DFS/BFS;边不断加入、需要快速连通性查询,用并查集。路径压缩与按大小合并后,并查集单次操作的均摊复杂度近似 O(1)

并查集的 API、复杂度直觉、带权/可撤销等扩展见 并查集;本节聚焦它在图连通性中的使用。

网格图:岛屿数量

二维网格可看成图:每个陆地格是顶点,上下左右相邻的陆地之间有边。LeetCode 200 的关键是每发现一块未访问陆地就计数一次,并用 DFS 将整座岛标记为水。

int numIslands(char[][] grid) {
    int count = 0;
    for (int r = 0; r < grid.length; r++) {
        for (int c = 0; c < grid[0].length; c++) {
            if (grid[r][c] == '1') {
                count++;
                flood(grid, r, c);
            }
        }
    }
    return count;
}

void flood(char[][] grid, int r, int c) {
    if (r < 0 || r == grid.length || c < 0 || c == grid[0].length || grid[r][c] != '1') return;
    grid[r][c] = '0';
    flood(grid, r + 1, c); flood(grid, r - 1, c);
    flood(grid, r, c + 1); flood(grid, r, c - 1);
}
题目平台建模与模板
547. 省份数量LeetCode邻接矩阵 + DFS / 并查集计数
200. 岛屿数量LeetCode网格图 + DFS / BFS 染色
1319. 连通网络的操作次数LeetCode并查集组件数 + 冗余边

有向图的“强连通分量”要求任意两点可以双向到达,需用 Tarjan 或 Kosaraju;它是本节无向连通分量的进阶主题。

强连通分量(SCC)

有向图中,普通“从 A 能到 B”不代表 B 能回到 A。强连通分量(Strongly Connected Component, SCC)是一个极大的顶点集合:集合内任意两点都能沿有向边互相到达。

graph scc tarjan

将每个 SCC 缩成一个点后,得到的“缩点图”一定是 DAG;这使得课程依赖、调用环、控制流分析等问题可以先识别环,再在 DAG 上做拓扑排序或 DP。

Tarjan 算法:一次 DFS 找出所有 SCC

为什么有向图求 SCC 不能简单 DFS?普通 DFS 只能判断"A 能到 B",但 SCC 要求双向可达,而 DFS 树的方向是固定的。Tarjan 的精妙之处在于三个观察叠加:

观察一:SCC 在 DFS 树上是一段连续子树。 一个 SCC 内所有节点互相可达,DFS 必然从其中一个节点进入并遍历完整个 SCC 才会回溯——因此一个 SCC 对应 DFS 树上一棵子树,其是该 SCC 中 dfn 最小的节点。

观察二:根的 low == dfn SCC 的根是组内最早被访问的,它的子树(含回边)怎么走都跑不出这个 SCC,所以 low[u] == dfn[u] 就是根的标志。反之 low[u] < dfn[u] 说明 u 能到达更早的祖先,u 不是根,它的 SCC 要等那个祖先弹栈时一并弹出。

观察三:栈只留"待定"节点。 已确定为完整 SCC 的节点出栈后,不能再被回边连回去(它们已闭合)。所以回边更新 low只认仍在栈中的祖先inStack[v]),否则会把别的 SCC 的节点错误拉进来。

三者结合得到 Tarjan 的状态定义与弹栈时机:

状态含义
dfn[u]u 第一次被 DFS 访问的时间
low[u]从 u 出发、经过 DFS 子树和至多一条回边,能到达的最小 dfn
inStack[u]u 是否仍在当前 DFS 路径栈中(待定 SCC)

low[u] == dfn[u]:u 是其 SCC 的根,从栈顶弹到 u,弹出的所有节点构成一个完整 SCC。

class TarjanScc {
    private final List<Integer>[] graph;
    private final int[] dfn, low;
    private final boolean[] inStack;
    private final Deque<Integer> stack = new ArrayDeque<>();
    private int time = 0;
    private int sccCount = 0;

    TarjanScc(List<Integer>[] graph) {
        this.graph = graph;
        int n = graph.length;
        dfn = new int[n];
        low = new int[n];
        inStack = new boolean[n];
    }

    int count() {
        for (int u = 0; u < graph.length; u++) {
            if (dfn[u] == 0) tarjan(u);
        }
        return sccCount;
    }

    private void tarjan(int u) {
        dfn[u] = low[u] = ++time;
        stack.push(u);
        inStack[u] = true;
        for (int v : graph[u]) {
            if (dfn[v] == 0) {
                tarjan(v);
                low[u] = Math.min(low[u], low[v]); // DFS 树边
            } else if (inStack[v]) {
                low[u] = Math.min(low[u], dfn[v]); // 指向栈内祖先的回边
            }
        }
        if (low[u] == dfn[u]) {
            sccCount++;
            while (true) {
                int v = stack.pop();
                inStack[v] = false;
                if (v == u) break;
            }
        }
    }
}

⚠️ 更新 low[u] 时,只有 v 仍在栈内才可使用回边;已出栈的节点属于已确定的其他 SCC,不能把它们混入当前组件。

无向图的桥(关键连接)

桥是边的性质,不是点的性质。 无向边 (u, v) 若删除后使连通分量数量增加,它就是桥;等价地,这条边不属于任何环

这正是 LeetCode 1192「查找集群内的关键连接」所求的对象。注意:不能只找”不在环上的节点”——两个环之间的一条连接边仍是桥,但它的两个端点都可能在各自的环上。下面由简入深,一步步推出 Tarjan 的判定。

第一步:暴力思路与它的代价

最直白的做法:枚举每条边,删掉它后跑一次 DFS/BFS 看连通分量是否变多。一次判断 O(V+E),枚举 E 条边共 O(E·(V+E)),n=10⁵ 时必超时。

Tarjan 的目标:只跑一次 DFS 就找出所有桥。关键在于 DFS 会把图变成一棵树,从而把边分成两类。

第二步:DFS 把图变成树

从任一节点出发做 DFS,走过的边分为两类:

边的类型含义在 DFS 中的角色
树边(tree edge)DFS 真正向下递归走过的边构成 DFS 生成树
回边(back edge)指向已访问过的祖先的边不在树上的”捷径”

无向图中,已访问的非父邻居一定是祖先(不存在横叉边,详见 DFS 的边分类),这是 Tarjan 成立的前提。

graph TD
    A((A)) -->|树边| B((B))
    B -->|树边| C((C))
    C -->|树边| D((D))
    C -.->|回边| A
    linkStyle 3 stroke:#e88,stroke-dasharray:5 5

回边是桥的天敌:一条边是桥,意味着它所在的子树没有任何回边能绕开它通往上方。于是问题转化为——“v 的子树里有没有回边能连到 u 或 u 的祖先?”

第三步:两个时间戳 dfn 与 low

数组含义
dfn[u]u 第一次被 DFS 访问的时间戳(全局递增)
low[u]从 u 出发,沿 DFS 子树往下走、至多再走一条回边,能到达的最小 dfn

low[u] 直觉上就是”u 的子树能攀到的最高祖先的 dfn”。若 low[u] 很小,说明 u 这一支有回边通往很靠上的祖先,根子扎得深。

第四步:low 的三条更新规则

场景更新操作说明
初始化low[u] = dfn[u] = ++time自己只能到自己
树边 u→vdfs(v) 返回后 low[u] = min(low[u], low[v])子树的 low 传递上来
回边 u→wlow[u] = min(low[u], dfn[w])能直接攀到祖先 w

⚠️ 回边更新用 dfn[w] 而非 low[w]:回边只算”一条”捷径,不能把 w 子树里别的回边也捎带过来。

第五步:桥的判定条件

树边 (u, v)(u 是 v 的父亲):

low[v] > dfn[u](u, v) 是桥

直觉证明:想象删掉边 (u, v)。v 的子树(v 及其所有后代)若不想被孤立、还能连到子树之外的部分(u 及 u 的祖先),只能靠回边——无向图 DFS 里从子树通往上方祖先的边只有回边这一种(见 DFS 的边分类)。low[v] 记录的正是 v 子树通过回边能到达的最高祖先的 dfn:

  • low[v] > dfn[u]:v 子树够不到 u 和 u 的祖先,出路只有 (u, v) 这一条 → 是桥
  • low[v] ≤ dfn[u]:存在回边从 v 子树连回 u 或更高处,删掉 (u, v) 仍连通 → 不是桥

第六步:与割点的区别(只差一个等号)

判定割点(关节点)
条件low[v] >= dfn[u]low[v] > dfn[u]
为何不同回边连回 u 自身时,u 被删照样断子树回边连回 u 自身时,边 (u,v) 没删,子树仍可达 u

low[v] == dfn[u] 时:回边直接搭到 u,所以 (u, v) 不是桥,但 u 仍是割点。此外根节点对桥无需特殊处理(不像割点要数根有几个孩子)。

与 SCC 版 Tarjan 的区别

维度SCC(有向图)桥(无向图)
目标找互相可达的点集找删除后会断开的边
low 的用途判断 low[u] == dfn[u],弹栈形成 SCC对 DFS 树边判断 low[v] > dfn[u]
栈 / inStack需要,维护尚未归属 SCC 的节点不需要
父边无此特殊处理必须跳过进入当前节点的那条父边

Java 模板:寻找所有桥

题目保证没有重边,因此用父节点 parent 跳过父边即可。若允许重边,应改为记录边编号并只跳过进入当前节点的那一条边。

class TarjanBridge {
    private final List<Integer>[] graph;
    private final int[] dfn;
    private final int[] low;
    private final List<List<Integer>> bridges = new ArrayList<>();
    private int time;

    TarjanBridge(List<Integer>[] graph) {
        this.graph = graph;
        dfn = new int[graph.length];
        low = new int[graph.length];
    }

    List<List<Integer>> findBridges() {
        for (int u = 0; u < graph.length; u++) {
            if (dfn[u] == 0) {
                dfs(u, -1);
            }
        }
        return bridges;
    }

    private void dfs(int u, int parent) {
        dfn[u] = low[u] = ++time;
        for (int v : graph[u]) {
            if (v == parent) {
                continue; // 无向边会在邻接表中出现两次,跳过父边的反向记录
            }
            if (dfn[v] == 0) {
                dfs(v, u);
                low[u] = Math.min(low[u], low[v]); // DFS 树边
                if (low[v] > dfn[u]) {
                    bridges.add(Arrays.asList(u, v));
                }
            } else {
                low[u] = Math.min(low[u], dfn[v]); // 指向祖先的回边
            }
        }
    }
}

完整示例:LeetCode 1192

n = 4, connections = [[0,1],[1,2],[2,0],[1,3]]:三角形 0-1-2 上挂一个叶子 3。从 0 开始 DFS,树边为 0-11-21-3,回边为 2-0

graph TD
    N0["0<br/>dfn=1, low=1"] --> N1["1<br/>dfn=2, low=1"]
    N1 --> N2["2<br/>dfn=3, low=1"]
    N2 -.-> N0
    N1 --> N3["3<br/>dfn=4, low=4"]
    style N3 fill:#fcc,stroke:#c00,stroke-width:2px
    linkStyle 2 stroke:#e88,stroke-dasharray:5 5
    linkStyle 3 stroke:#c00,stroke-width:3px

逐节点计算 dfn / low

节点dfnlow计算过程
011起点
121被 0 访问
231回边 2-0low[2] = min(3, dfn[0]=1) = 1
344叶子,无回边

逐条判定树边:

树边判定是否桥
(0,1)low[1]=1 > dfn[0]=1 ?
(1,2)low[2]=1 > dfn[1]=2 ?否(有回边绕开)
(1,3)low[3]=4 > dfn[1]=2 ?

答案 1,3,与题目一致。

时间复杂度为 O(V + E),空间复杂度为 O(V)。Java 的递归 DFS 在退化链上可能达到 10^5 层; 若运行环境栈较小,需要改为显式栈模拟 DFS;后序、回溯或需要回传子结果的算法,则应把调用帧与恢复阶段都显式建模,见 显式栈与状态机替代递归

阅读顺序

  1. 先在纸上画出一个三角形外接一条尾边,手动写出每个节点的 dfnlow
  2. 对照模板理解两处 low 更新:子节点返回后取 low[v],遇到已访问的祖先时取 dfn[v]
  3. 最后阅读 cp-algorithms 的桥算法文章,它给出了判定的推导和完整参考实现。

Tarjan 与 Kosaraju

算法核心做法时间 / 空间适合
Tarjan一次 DFS + 栈 + dfn/lowO(V+E) / O(V)常用模板,单图遍历
Kosaraju原图 DFS 完成时间 + 反图 DFSO(V+E) / O(V+E)已方便构造反图时,直觉更直接
题目平台核心
2360. 图中的最长环LeetCode功能图中的环与访问时间
1192. 查找集群内的关键连接LeetCode同用 dfn/low,但寻找桥而非 SCC
802. 找到最终的安全状态LeetCode反图拓扑,理解环与 DAG

最短路径

Dijkstra 算法

适用于无负权边的带权图,求单源最短路径。

核心思路:贪心。维护 dist[] 数组记录起点到每个顶点的当前最短距离,每次从未处理的顶点中取 dist 最小的,用它松弛所有邻居。

为什么贪心是对的? 取出 dist 最小的未处理点 u 时,u 的距离已不可能再变小——任何其他未处理点的 dist 都 ≥ u,经过它们绕到 u 只会更长(边权非负)。所以 u 的最短路此时就被"锁定",标记为已处理不再动。这也解释了为何有负权就失效:若存在负权边,绕路反而更短,"dist 最小即确定"的假设不成立,贪心提前锁定会错过更短的负权绕行。

识别信号:单源、带权、无非负权 → Dijkstra;"最短时间/距离"且无负权时优先它。

graph dijkstra

松弛操作:若 dist[u] + w(u,v) < dist[v],则更新 dist[v]

public int[] dijkstra(int src) {
    int[] dist = new int[v];
    boolean[] processed = new boolean[v];
    Arrays.fill(dist, Integer.MAX_VALUE);
    dist[src] = 0;

    // 优先队列:(distance, vertex)
    PriorityQueue<int[]> pq = new PriorityQueue<>(Comparator.comparingInt(a -> a[0]));
    pq.offer(new int[]{0, src});

    while (!pq.isEmpty()) {
        int[] curr = pq.poll();
        int u = curr[1];
        if (processed[u]) continue;
        processed[u] = true;

        for (int[] edge : adjWeighted[u]) { // [neighbor, weight]
            int neighbor = edge[0], weight = edge[1];
            if (dist[u] + weight < dist[neighbor]) {
                dist[neighbor] = dist[u] + weight;
                pq.offer(new int[]{dist[neighbor], neighbor});
            }
        }
    }
    return dist;
}
实现时间复杂度说明
朴素实现(线性扫描)O(V²)适合稠密图
优先队列优化O((V+E) log V)适合稀疏图

Bellman-Ford 算法

适用于有负权边的图,可检测负权环

核心思路:对所有边做 V−1 轮松弛。第 k 轮结束后,dist[v] 是从源点经过至多 k 条边的最短距离。若第 V 轮仍能松弛,说明存在负权环。

为什么是 V−1 轮? 一条最短路径若是简单路径(不含环),最多经过 V−1 条边——只有 V 个顶点,第 V 条边必然回到已访问过的点形成环。最短路不会含正权环(绕了更长无意义),但可能含负权环(可无限绕短)。所以 V−1 轮松弛足以传播完所有简单最短路;若第 V 轮还能松弛,说明最短路里掺了负权环(可以一直绕下去变短),据此检测。

识别信号:图中有负权边,或需检测负权环(如"汇率套利""能否无限变短")→ Bellman-Ford。

public int[] bellmanFord(int src) {
    int[] dist = new int[v];
    Arrays.fill(dist, Integer.MAX_VALUE);
    dist[src] = 0;

    for (int i = 0; i < v - 1; i++) {        // V-1 轮
        for (int[] edge : edges) {            // 遍历所有边 (u, v, w)
            int u = edge[0], nv = edge[1], w = edge[2];
            if (dist[u] != Integer.MAX_VALUE && dist[u] + w < dist[nv])
                dist[nv] = dist[u] + w;
        }
    }
    // 检测负权环:若第 V 轮仍能松弛则有负权环
    for (int[] edge : edges) {
        if (dist[edge[0]] + edge[2] < dist[edge[1]])
            throw new RuntimeException("存在负权环");
    }
    return dist;
}

最短路径算法对比

算法负权边负权环检测时间复杂度适用场景
DijkstraO((V+E) log V)地图导航、无负权
Bellman-FordO(VE)货币套利检测、有负权
Floyd-WarshallO(V³)全源最短路径(V 较小)

最小生成树

最小生成树(MST):在连通无向带权图中,找一棵包含所有 V 个顶点、仅用 V−1 条边、且边权之和最小的树。

graph mst

贪心为什么正确:切边定理

Prim 和 Kruskal 都是贪心,能保证正确靠的是同一条定理——切边定理(Cut Property)

把顶点集任意切成 S 和 V−S 两部分,横跨两部分的最短边一定属于某棵最小生成树。

直觉:假设最短横跨边 e 不在 MST 里,那 MST 必有另一条横跨边 f 连接 S 和 V−S;用 e 替换 f,总权更小且不形成环(e、f 都横跨同一刀,换掉不破坏连通),矛盾。所以 e 必在 MST 中。

两个算法都是在反复"切一刀、取最短横跨边":

算法怎么切取哪条
PrimS = 已选点集,V−S = 未选点集这刀的最短横跨边
Kruskal按边权排序,加的边连接两个当前不在同一连通分量的点相当于切那两个分量,取最短横跨边

识别信号:"用最小代价把所有点连成连通""联网""连接所有城市" → MST。稠密图(E≈V²)选 Prim,稀疏图选 Kruskal(排序主导,O(E log E))。

Prim 算法

思路:从任意顶点出发,维护"已选集合 S",每次选择连接 S 与非 S 的最小权边,将新顶点并入 S,重复 V−1 次。

S = {start}
重复 V-1 次:
  在所有 (u, v) 边中,u ∈ S,v ∉ S,选权重最小的边
  将 v 加入 S,记录边 (u, v)
  • 时间:O(V²),优先队列优化后 O((V+E) log V)
  • 适合稠密图(E ≈ V²,优先队列优势不明显)

Kruskal 算法

思路:将所有边按权重从小到大排序,依次考察每条边——若加入后不形成环,则纳入 MST。用并查集判断成环。

public List<int[]> kruskal(int v, int[][] edges) {
    // edges: [u, v, weight],按 weight 排序
    Arrays.sort(edges, Comparator.comparingInt(e -> e[2]));
    int[] parent = new int[v];
    for (int i = 0; i < v; i++) parent[i] = i;

    List<int[]> mst = new ArrayList<>();
    for (int[] edge : edges) {
        int pu = find(parent, edge[0]);
        int pv = find(parent, edge[1]);
        if (pu != pv) {           // 不在同一连通分量,不成环
            mst.add(edge);
            parent[pu] = pv;      // 合并并查集
        }
        if (mst.size() == v - 1) break;
    }
    return mst;
}

private int find(int[] parent, int x) {
    if (parent[x] != x) parent[x] = find(parent, parent[x]); // 路径压缩
    return parent[x];
}
  • 时间:O(E log E)(排序主导)
  • 适合稀疏图

Prim vs Kruskal

维度PrimKruskal
出发点从某一顶点扩张从全局最小边开始
核心数据结构优先队列排序 + 并查集
时间复杂度O((V+E) log V)O(E log E)
适合图类型稠密图稀疏图

拓扑排序

拓扑排序:对有向无环图(DAG)的顶点进行线性排序,使每条边 (u→v) 中 u 都排在 v 之前。

典型应用:编译依赖、任务调度、课程先修关系、Makefile 构建顺序。

graph topo

Kahn 算法(BFS + 入度)

public List<Integer> topoSort(int v, int[][] edges) {
    int[] inDegree = new int[v];
    List<List<Integer>> adj = new ArrayList<>();
    for (int i = 0; i < v; i++) adj.add(new ArrayList<>());

    for (int[] e : edges) {
        adj.get(e[0]).add(e[1]);
        inDegree[e[1]]++;
    }

    Queue<Integer> queue = new LinkedList<>();
    for (int i = 0; i < v; i++)
        if (inDegree[i] == 0) queue.add(i); // 所有入度为 0 的顶点入队

    List<Integer> result = new ArrayList<>();
    while (!queue.isEmpty()) {
        int u = queue.poll();
        result.add(u);
        for (int neighbor : adj.get(u)) {
            if (--inDegree[neighbor] == 0) // 入度归零,可以入队
                queue.add(neighbor);
        }
    }

    // 若结果不包含所有顶点,说明图中存在环(无法完成拓扑排序)
    if (result.size() < v) throw new RuntimeException("图中存在环,不是 DAG");
    return result;
}

为什么入度归零就能排? 入度为 0 表示该任务已无任何前置依赖,可安全输出;输出它后删掉它的出边,让后继的入度降 1,新暴露出的无依赖任务继续输出。若最终输出数 < V,说明有环——环里的点入度永远降不到 0。

步骤

  1. 统计所有顶点的入度
  2. 将入度为 0 的顶点加入队列
  3. 出队一个顶点 u,将其所有邻居的入度减 1;若某邻居入度变为 0,入队
  4. 重复直到队列为空
维度
时间复杂度O(V+E)
空间复杂度O(V)
检测环result.size() < V 即有环

DFS 拓扑排序

DFS 完成后逆序输出即为拓扑序(后处理时间越大的顶点排越前),适合递归场景。

为什么后序逆序就是拓扑序? DFS 在"某顶点的所有后继都处理完"后才把它入栈(后序)。于是栈中越靠后的顶点,其依赖越早处理完——逆序输出时,被依赖的顶点先于依赖它的顶点出现,正好满足"边 u→v 中 u 排在 v 前"。若 DFS 中遇到指向栈内未完成祖先的回边,说明存在环,不是 DAG。

public List<Integer> topoSortDFS(int v, List<List<Integer>> adj) {
    boolean[] visited = new boolean[v];
    Deque<Integer> stack = new ArrayDeque<>();
    for (int i = 0; i < v; i++)
        if (!visited[i]) dfsHelper(i, adj, visited, stack);
    List<Integer> result = new ArrayList<>(stack); // 栈顶 = 拓扑序第一个
    return result;
}

private void dfsHelper(int u, List<List<Integer>> adj, boolean[] visited, Deque<Integer> stack) {
    visited[u] = true;
    for (int neighbor : adj.get(u))
        if (!visited[neighbor]) dfsHelper(neighbor, adj, visited, stack);
    stack.push(u); // 所有后继处理完后才入栈
}

图题解题决策框架

学完各个算法,真正做题时第一步不是写代码,而是判断这道题该用哪个算法。判断分三步走。

第一步:把题目抽象成图

绝大多数图题不会直接给你"图"的数据结构,而是披着场景外衣。先问自己两个问题:

问题例子
什么当作顶点岛屿题里每个格子是顶点;课程表里每门课是顶点
什么当作格子上下左右相邻有边;先修关系是一条有向边

抽象完,再确定三个属性:有向/无向带权/无权稀疏/稠密。这三个属性直接决定算法选择。

第二步:按问题类型选算法

题目问什么关键词信号选用算法复杂度
两点间边数最少的路径 / 最少步数"最少步数""最少操作""无权图"BFSO(V+E)
两点间权重最短的路径"最短时间/距离""带权"Dijkstra(无负权)/ Bellman-Ford(有负权)O((V+E)logV) / O(VE)
任意两点最短路(n 小)"所有点对""n≤100"Floyd-WarshallO(V³)
最少代价把所有点连成连通"联网""连接所有点""最小代价"MST:Prim 稠密 / Kruskal 稀疏O(V²) / O(ElogE)
连通块个数 / 是否连通"岛屿数""省份数""连通分量"DFS/BFS 染色(静态)/ 并查集(动态加边)O(V+E) / 近似 O(1)
任务先后顺序 / 能否完成"依赖""先修""编译顺序"拓扑排序O(V+E)
删哪条边会断开"关键连接""关键边"Tarjan 求桥O(V+E)
删哪个点会断开"关键节点""割点"Tarjan 求割点O(V+E)
有向图互相可达的群体 / 环"强连通""循环依赖"Tarjan/Kosaraju 求 SCCO(V+E)

第三步:决策流程

graph TD
    Q[看到一道图题] --> A{抽象成图<br/>确定: 有向? 带权? 稀疏?}
    A -->|无权 + 最少步数| BFS[BFS]
    A -->|带权 + 单源最短| D{有负权?}
    D -->|无| Di[Dijkstra]
    D -->|有| BF[Bellman-Ford]
    A -->|所有点对 + n小| F[Floyd-Warshall]
    A -->|连接所有点 + 最小代价| MST[MST: Prim/Kruskal]
    A -->|连通块或合并集合| C{边是否动态加入}
    C -->|静态| DFS2[DFS/BFS 染色]
    C -->|动态| UF[并查集]
    A -->|有向 + 依赖顺序| T[拓扑排序]
    A -->|无向 + 关键边或点| TB[Tarjan 桥/割点]
    A -->|有向 + 强连通或环| SCC[Tarjan 求 SCC]
    style BFS fill:#cfc,stroke:#060
    style Di fill:#cfc,stroke:#060
    style UF fill:#cfc,stroke:#060

常见陷阱信号

信号含义对策
二维网格 / 矩阵隐式图,格子=点,上下左右=边DFS/BFS 染色
"最少步数"且每步代价相同等价于无权图最短用 BFS,不是 DFS
有负权Dijkstra 的贪心假设失效改 Bellman-Ford 或 SPFA
n ≤ 100 且求"任意两点"全源最短路规模小Floyd O(n³)
链状图且 n=1e5递归 DFS 栈深改显式栈迭代
"能否到达"反复查询多次连通性判断并查集而非每次 DFS

核心心法:先抽象(点/边/方向/权),再看问的"量"是什么(最短/最小/连通/顺序/关键),最后匹配算法。同一问题常有多种解法,按约束(n 大小、有无负权、边静态动态)二次筛选。

参考资料

← 返回列表
(1 人打了分,平均分: 5.00)

评论 (0)

暂无评论,来留下第一条吧。
登录注册 后才能发表评论