图算法(上)——遍历与最短路径 — 从零精通算法与数据结构——Google 面试系统备战 第13篇

第13章:图算法(上)——遍历与最短路径

本章目标

读完本章你会:

  • 灵活选择邻接表 vs 邻接矩阵,说出各自的复杂度利弊
  • 区分 BFS 的最短路径性质和 DFS 的回溯性质,在合适场景使用
  • 手写 Kosaraju 或 Tarjan 求强连通分量
  • 画出最短路算法决策树:Dijkstra → Bellman-Ford → Floyd-Warshall
  • 理解 DAG 上的拓扑序最短路是 O(V+E) 的线性奇迹

知识讲解

从一个生活例子开始

BFS = 广度优先: 你从家出发找最近的咖啡店。先看周围 100 米内有没有,没有再看 200 米……保证找到的一定是最近的。你用一个队列维护"接下来要检查谁"——先近后远。

DFS = 深度优先: 你走进一座迷宫,每到岔路口,总是选最右边那条路走到死路,然后回溯到上一个岔口换一条路。你用一个栈(或递归)记录"我从哪来的,还有什么岔路没试"。

Dijkstra = 带代价的 BFS: 每条路有了"过路费",优先检查总花费最低的路径。你不是按距离圈层层扩,而是按"累计花费"排序——用优先队列。

工作原理

13.1 图的表示

邻接表 邻接矩阵
空间 O(V+E) O(V²)
检查边(u,v) O(deg(u)) O(1)
遍历所有边 O(V+E) O(V²)
适用 稀疏图(大多数实际图) 稠密图、Floyd-Warshall

C++ 中:

// 邻接表(最常用)——每个节点存邻居列表
std::vector<std::vector<int>> adj(V);
adj[u].push_back(v);

// 带权图
std::vector<std::vector<std::pair<int, int>>> adj(V);  // (neighbor, weight)

13.2 BFS vs DFS 深度对比

BFS: 一层一层扫,找到的路径是边数最少的。

应用:
- 无权图最短路径
- Word Ladder(单词接龙)
- 社交网络"几度人脉"
- 岛屿数量(Number of Islands)—— BFS/DFS 都可以

DFS: 一条路走到黑,回溯信息保存在栈上。

应用:
- 拓扑排序(DFS 后序反序)
- 找环检测
- 连通分量
- 迷宫生成
- 回溯问题(排列、组合、N皇后)

BFS vs DFS 在面试中的选择:

题目特征 用哪个 原因
"最短"、"最少步数" BFS BFS 找到的第一条路径就是边数最少的
"所有路径"、"排列组合" DFS + 回溯 DFS 天然支持回溯
图非常大,只需找到一个 DFS 空间 O(h),远好于 BFS O(w)
逐层处理 BFS 层序遍历天然需要 BFS

13.3 拓扑排序

定义: 有向图的线性排序,使得每条边 (u→v) 中 u 在 v 之前。

应用: 课程安排、依赖解析、编译排序。

Kahn 算法(BFS 法):

1. 计算所有节点的入度
2. 入度为 0 的节点入队
3. 出队 → 输出 → 所有邻居入度 -1 → 新入度为 0 者入队
4. 重复,最后若输出节点数 ≠ V,则图有环

Tarjan DFS 法: DFS 后序遍历的反序 → 拓扑排序。DFS 的过程中发现回边(back edge)则图有环。

13.4 强连通分量 (SCC)

定义: 有向图的最大子图,其中任意两节点互相可达。

Kosaraju 算法(两次 DFS):

1. DFS(G),按完成时间的反序(后序)记录节点
2. 反转 G 的所有边 → G^T
3. DFS(G^T),按第 1 步的顺序处理——每棵 DFS 树就是一个 SCC

Kosaraju 的直觉:反转图后,SCC 内部仍然连通(因为互相可达),但 SCC 之间的边方向被反转,因此第二次 DFS 被限制在一个 SCC 内。

Tarjan 算法(一次 DFS): 维护 dfn(发现时间)和 low(能回溯到的最早祖先),当 dfn[u] = low[u] 时,栈中 u 及以上的节点构成一个 SCC。仅需一次 DFS。

13.5 最短路算法决策树

所有边权重 > 0?
  ├─ Yes → Dijkstra O((V+E) log V)
  │         (稠密图考虑 O(V²) 的朴素版本)
  ├─ No → 有负权边,有负环?
  │         ├─ 只需单源 → Bellman-Ford O(V·E),能检测负环
  │         └─ 需要所有点对
  │               ├─ 有负环 → 不适用
  │               └─ 无负环 → Floyd-Warshall O(V³)
  └─ 图是 DAG → 拓扑序松弛 O(V+E) ✨ 线性!

DAG 最短路是线性时间的原因: 按拓扑序处理,每条边 (u→v) 只被放松一次(当 u 被处理时)。没有回边需要重新检查。

Dijkstra 为什么不能处理负权边?

Dijkstra 的贪心不变式是"出队节点的距离是最终值"。这个不变式依赖于所有边非负——如果存在负边,一条经过更多边的路径可能比当前的"最短"路径更短,但该节点已经被错误地"锁定"了。

Bellman-Ford 直观理解:

最短简单路径最多有 V-1 条边。第 k 轮松弛后,所有最多 k 条边的最短路径都是正确的。因此 V-1 轮后全图正确。再一轮还能更新 → 有负环。


代码实战

include/algo/graph.h(核心接口)

#ifndef ALGO_GRAPH_H_
#define ALGO_GRAPH_H_

#include <cstdint>
#include <limits>
#include <queue>
#include <vector>

namespace algo {

// ========== 图的邻接表表示 ==========
template <typename WeightType = int>
class WeightedGraph {
 public:
  explicit WeightedGraph(int vertices)
      : adj_(vertices), V_(vertices) {}

  void AddEdge(int from, int to, WeightType weight, bool directed = true) {
    adj_[from].emplace_back(to, weight);
    if (!directed) {
      adj_[to].emplace_back(from, weight);
    }
  }

  int V() const { return V_; }
  const auto& Neighbors(int v) const { return adj_[v]; }

 private:
  std::vector<std::vector<std::pair<int, WeightType>>> adj_;
  int V_;
};

using Graph = WeightedGraph<int>;

// ========== 拓扑排序(Kahn 算法)==========
// 返回拓扑序,若图有环则返回空
std::vector<int> TopologicalSort(const Graph& graph);

// ========== BFS 最短路(无权图)==========
// 返回从 src 到每个节点的最短距离(边数),不可达为 -1
std::vector<int> BfsDistances(const Graph& graph, int src);

// ========== Dijkstra 最短路 ==========
constexpr int64_t kInf = std::numeric_limits<int64_t>::max();
std::vector<int64_t> Dijkstra(const Graph& graph, int src);

// ========== Bellman-Ford ==========
// 返回 {distances, has_negative_cycle}
struct BellmanFordResult {
  std::vector<int64_t> distances;
  bool has_negative_cycle = false;
};
BellmanFordResult BellmanFord(const Graph& graph, int src);

// ========== Kosaraju SCC ==========
// 返回每个节点所属的 SCC 编号
std::vector<int> KosarajuSCC(const Graph& graph);

}  // namespace algo

#endif  // ALGO_GRAPH_H_

include/algo/graph_impl.h(核心实现——Dijkstra 和 Bellman-Ford)

namespace algo {

inline std::vector<int> TopologicalSort(const Graph& graph) {
  int V = graph.V();
  std::vector<int> indegree(V, 0);
  for (int u = 0; u < V; ++u) {
    for (auto& [v, w] : graph.Neighbors(u)) ++indegree[v];
  }

  std::queue<int> q;
  for (int v = 0; v < V; ++v) {
    if (indegree[v] == 0) q.push(v);
  }

  std::vector<int> order;
  while (!q.empty()) {
    int u = q.front(); q.pop();
    order.push_back(u);
    for (auto& [v, w] : graph.Neighbors(u)) {
      if (--indegree[v] == 0) q.push(v);
    }
  }
  if (static_cast<int>(order.size()) != V) return {};  // 有环
  return order;
}

inline std::vector<int64_t> Dijkstra(const Graph& graph, int src) {
  int V = graph.V();
  std::vector<int64_t> dist(V, kInf);
  dist[src] = 0;

  // 最小堆:(distance, vertex)
  using State = std::pair<int64_t, int>;
  std::priority_queue<State, std::vector<State>, std::greater<State>> pq;
  pq.emplace(0, src);

  while (!pq.empty()) {
    auto [d, u] = pq.top(); pq.pop();
    if (d != dist[u]) continue;  // 旧条目,跳过(懒惰删除)

    for (auto& [v, w] : graph.Neighbors(u)) {
      if (dist[u] + w < dist[v]) {
        dist[v] = dist[u] + w;
        pq.emplace(dist[v], v);
      }
    }
  }
  return dist;
}

inline BellmanFordResult BellmanFord(const Graph& graph, int src) {
  int V = graph.V();
  std::vector<int64_t> dist(V, kInf);
  dist[src] = 0;

  // V-1 轮松弛
  for (int round = 0; round < V - 1; ++round) {
    bool updated = false;
    for (int u = 0; u < V; ++u) {
      if (dist[u] == kInf) continue;
      for (auto& [v, w] : graph.Neighbors(u)) {
        if (dist[u] + w < dist[v]) {
          dist[v] = dist[u] + w;
          updated = true;
        }
      }
    }
    if (!updated) break;  // 早期退出优化
  }

  // 第 V 轮:检测负环
  bool has_negative_cycle = false;
  for (int u = 0; u < V; ++u) {
    if (dist[u] == kInf) continue;
    for (auto& [v, w] : graph.Neighbors(u)) {
      if (dist[u] + w < dist[v]) {
        has_negative_cycle = true;
        break;
      }
    }
  }

  return {dist, has_negative_cycle};
}

}  // namespace algo

本章小结

  1. BFS 在无权图上找最短路径(最少边数),用 FIFO 队列
  2. DFS 用于拓扑排序、连通分量、回溯搜索
  3. Dijkstra 处理非负权边的最短路,核心是不变式 + 优先队列
  4. Bellman-Ford 处理含负权边的最短路,V-1 轮松弛 + 1 轮检测负环
  5. 拓扑排序 + 松弛 = DAG 最短路 O(V+E),是所有最短路算法中最快的
  6. SCC(Kosaraju/Tarjan)解决"哪些节点互相可达"的问题

关键术语

术语 释义
BFS 广度优先搜索——逐层扩展,保证无权图最短路径
DFS 深度优先搜索——一路到底,天然支持回溯和拓扑排序
Dijkstra 非负权图最短路径算法,每轮选距离最小的未处理节点
Bellman-Ford 含负权边的最短路算法,V-1 轮松弛所有边
SCC 强连通分量——有向图中任意两节点互相可达的最大子图
拓扑排序 DAG 的线性排序,使得每条有向边(u→v)中 u 排在 v 前
posted @ 2026-06-22 01:09  Yobeeo  阅读(11)  评论(0)    收藏  举报