网络算法:从图论基础到深度学习应用(万字长文)

图论这东西,大学时候觉得只是个理论玩具,直到工作后被按在地上摩擦了几次才明白——不是图论没用,是你没碰到需要用图的场景。

从社交推荐到知识图谱,从路由算法到代码依赖分析,从交通规划到分子结构预测——几乎所有你叫得上名字的互联网公司,核心业务里都有图的影子。Google 的 PageRank、美团的外卖调度、滴滴的拼车算法、淘宝的商品推荐……这些本质上都是图算法。

这篇文章不跟你扯虚的,直接从代码能跑的角度,把图论基础 → 经典算法 → 图神经网络(GNN)这条线串清楚。


一、图的基本概念:先搞清楚这几件事

一个图由两部分组成:顶点(Vertex/Node)边(Edge)。顶点表示"东西",边表示"关系"。

社交网络:        用户 = 顶点,好友关系 = 边
知识图谱:        实体 = 顶点,实体关系 = 边
代码依赖图:      模块 = 顶点,导入关系 = 边
交通网络:        站点 = 顶点,路线 = 边

1.1 图的分类

类型 特点 例子
无向图 边没有方向 好友关系、道路连接
有向图 边有方向 网页链接、关注关系
加权图 边带权重 最短路径、流量分析
异构图 多种顶点/边类型 知识图谱、推荐系统

1.2 图的表示方法(直接上代码)

# 1. 邻接表(最常用,省内存)
graph = {
    "A": ["B", "C"],
    "B": ["A", "D", "E"],
    "C": ["A", "F"],
    "D": ["B"],
    "E": ["B", "F"],
    "F": ["C", "E"],
}

# 2. 邻接矩阵(适合稠密图,查询 O(1))
import numpy as np
nodes = ["A", "B", "C", "D", "E", "F"]
n = len(nodes)
adj_matrix = np.zeros((n, n), dtype=int)
edges = [("A","B"), ("A","C"), ("B","D"), ("B","E"), ("C","F"), ("E","F")]
for u, v in edges:
    i, j = nodes.index(u), nodes.index(v)
    adj_matrix[i][j] = 1
    adj_matrix[j][i] = 1  # 无向图

# 3. NetworkX(生产环境推荐)
import networkx as nx
G = nx.Graph()
G.add_edges_from([("A","B"), ("A","C"), ("B","D"), ("B","E"), ("C","F"), ("E","F")])

选型建议:日常分析和可视化用 NetworkX;需要高性能用 igraph 或 cuGraph(GPU 加速);生产环境用 Neo4j 或 NebulaGraph。


二、图的遍历:BFS 和 DFS 是万法之源

不管多复杂的图算法,底层的"走路方式"就两种:广度优先(BFS)深度优先(DFS)

from collections import deque

# BFS:用队列,找最短路径
def bfs(graph, start):
    visited = set()
    queue = deque([start])
    visited.add(start)
    
    while queue:
        node = queue.popleft()
        print(f"访问: {node}")
        
        for neighbor in graph[node]:
            if neighbor not in visited:
                visited.add(neighbor)
                queue.append(neighbor)
    return visited

# DFS:用栈(或递归),用来判断连通性
def dfs(graph, start, visited=None):
    if visited is None:
        visited = set()
    visited.add(start)
    print(f"访问: {start}")
    
    for neighbor in graph[start]:
        if neighbor not in visited:
            dfs(graph, neighbor, visited)
    return visited

# 应用:检测图是否连通
def is_connected(graph):
    if not graph:
        return True
    start = next(iter(graph))
    visited = dfs(graph, start)
    return len(visited) == len(graph)
特性 BFS DFS
数据结构 队列 栈/递归
空间复杂度 O(V)(最坏) O(h)(h=树深)
最短路径 ✅ 无权图
拓扑排序
连通分量

2.1 拓扑排序:决定谁先做、谁后做

拓扑排序解决的是“有依赖关系的任务执行顺序”问题。它只适用于 DAG(有向无环图),输出一个线性序列,保证:若存在边 u→vu \to vuv,则 uuu 在排序中必须出现在 vvv 之前。

典型应用场景:

  • 源码编译(模块依赖):A 依赖 B,B 依赖 C → 编译顺序:C → B → A
  • 任务调度:工作流引擎中的先后顺序
  • 课程安排:先修课必须在后续课之前
Kahn 算法(基于入度)

重复以下操作:找一个入度为 0 的节点,输出并删除其所有出边。若最终输出的节点数少于总节点数,说明图中有环,无法完成拓扑排序。

from collections import deque, defaultdict

def kahn_topological_sort(graph):
    """
    graph: dict {节点: [后继节点列表]}
    返回: topological order list,若存在环则返回 None
    """
    # 计算入度
    in_degree = defaultdict(int)
    for u in graph:
        for v in graph[u]:
            in_degree[v] += 1
            if u not in in_degree:
                in_degree[u] = 0
    
    # 入度为 0 的节点入队
    queue = deque([u for u in graph if in_degree[u] == 0])
    result = []
    
    while queue:
        u = queue.popleft()
        result.append(u)
        for v in graph[u]:
            in_degree[v] -= 1
            if in_degree[v] == 0:
                queue.append(v)
    
    if len(result) != len(graph):
        return None  # 有环
    return result

# 示例:课程依赖
graph = {
    "C": ["A", "B"],
    "A": ["D"],
    "B": ["D"],
    "D": []
}
print(kahn_topological_sort(graph))  # ['C', 'A', 'B', 'D'] 或 ['C', 'B', 'A', 'D']
DFS 后序遍历法

对图进行 DFS,在回溯时记录节点。最终将记录逆序即得拓扑排序。这种方法更易于检测环的存在(配合三种颜色标记:白/灰/黑)。

def dfs_topological_sort(graph):
    WHITE, GRAY, BLACK = 0, 1, 2
    color = {u: WHITE for u in graph}
    order = []
    
    def dfs(u):
        if color[u] == GRAY:
            raise ValueError("图中有环,无法拓扑排序")
        if color[u] == BLACK:
            return
        color[u] = GRAY
        for v in graph.get(u, []):
            dfs(v)
        color[u] = BLACK
        order.append(u)
    
    for u in graph:
        if color[u] == WHITE:
            dfs(u)
    
    return order[::-1]  # 逆序

try:
    print(dfs_topological_sort(graph))  # ['C', 'A', 'B', 'D']
except ValueError as e:
    print(e)

BFS/DFS 是图算法真正的地基,拓扑排序只是其中一个延伸。后续讲的最短路径、关键节点分析,本质上都是遍历框架加上不同的「距离」或「重要性」定义。把这个地基打牢,后面的算法看本质就是换了个估值函数。

三、最短路径:不只是地图导航

3.1 Dijkstra —— 单源最短路径(无负权边)

算法思想

Dijkstra 采用贪心策略:每次从未确定最短距离的节点中,选出当前距离最小的节点,并以此节点为中介更新其邻居的距离。这一过程不断重复,直到所有节点都已确定最短路径。

为什么能贪心?

  • 无负权边保证了:当节点 uuu 被从优先队列中弹出时,其距离 dist[u]dist[u]dist[u] 已经是全图最优解。因为任何其他未探索的路径都会通过某个距离更大的节点中转,再加上非负的边权,总距离只会更大。
正确性证明(交换论证法)

命题:若所有边权 w(e)≥0w(e) \ge 0w(e)0,则 Dijkstra 算法结束时,dist[v]dist[v]dist[v] 等于从源点 sssvvv 的真正最短距离。

证明(反证法):

  1. 设算法第一次犯错的时刻是节点 uuu 被弹出优先队列时,dist[u]dist[u]dist[u] 不是真正的 s→us \to usu 最短距离。
  2. 那么存在一条更短的 s→us \to usu 路径 PPP,设其总长为 d<dist[u]d < dist[u]d<dist[u]
  3. 路径 PPP 上必存在第一个未被确定的节点 yyy(即 yyy 尚未被弹出),设 PPPyyy 的前驱 xxx 已被确定。
  4. 由于 xxx 被正确确定(因为 uuu 是第一个出错的节点),dist[x]dist[x]dist[x] 等于 s→xs \to xsx 的最短距离。
  5. 那么 dist[y]≤dist[x]+w(x,y)≤dist[y] \le dist[x] + w(x,y) \ledist[y]dist[x]+w(x,y) 路径 PPP 的总长 =d<dist[u]= d < dist[u]=d<dist[u]
  6. dist[y]<dist[u]dist[y] < dist[u]dist[y]<dist[u] 意味着 yyy 应该先于 uuu 被弹出,矛盾!

因此算法不会犯错,Dijkstra 的正确性得证。■\blacksquare

时间复杂度分析
操作 次数 每次复杂度 总复杂度
插入堆 VVV O(log⁡V)O(\log V)O(logV) O(Vlog⁡V)O(V \log V)O(VlogV)
提取最小 VVV O(log⁡V)O(\log V)O(logV) O(Vlog⁡V)O(V \log V)O(VlogV)
更新键值 EEE O(log⁡V)O(\log V)O(logV) O(Elog⁡V)O(E \log V)O(ElogV)
总计 O((V+E)log⁡V) O((V+E) \log V) O((V+E)logV)

优化潜力:使用斐波那契堆可将更新键值的摊还复杂度降为 O(1)O(1)O(1),从而总复杂度降至 O(Vlog⁡V+E)O(V \log V + E)O(VlogV+E)。但常数过大,工程上很少使用。

为什么不能处理负权边?

负权边破坏了贪心选择的前提——当一条边权为负时,通过距离更大的节点中转反而可能缩短路径,导致"当前最小"的节点未必是全局最优。这种场景需改用 Bellman-FordO(VE)O(VE)O(VE)),它能检测负权环。

代码实现
import heapq

def dijkstra(graph, start):
    """
    graph: dict {node: [(neighbor, weight), ...]}
    返回: distances dict
    """
    distances = {node: float("inf") for node in graph}
    distances[start] = 0
    # 优先队列: (当前距离, 节点)
    pq = [(0, start)]
    # 已确定最短路径的节点集合(可选,用于空间优化)
    visited = set()
    
    while pq:
        cur_dist, cur_node = heapq.heappop(pq)
        
        # 若该节点已被处理过,跳过(惰性删除)
        if cur_node in visited:
            continue
        visited.add(cur_node)
        
        # 松弛操作
        for neighbor, weight in graph[cur_node]:
            distance = cur_dist + weight
            if distance < distances[neighbor]:
                distances[neighbor] = distance
                heapq.heappush(pq, (distance, neighbor))
    
    return distances

# 测试
graph = {
    "A": [("B", 4), ("C", 2)],
    "B": [("C", 1), ("D", 5)],
    "C": [("D", 8), ("E", 10)],
    "D": [("E", 2)],
    "E": [],
}
print(dijkstra(graph, "A"))  # {'A':0, 'B':4, 'C':2, 'D':9, 'E':11}

关键实现细节

  • visited 集合用于避免重复处理节点(PQ 中可能存在同一个节点的多个 entry)
  • cur_dist > distances[cur_node] 时,说明该 entry 已过期,可以直接 continue
应用场景
  • 地图导航(OSM 路网)
  • 网络路由(OSPF 协议)
  • 物流配送路径规划
  • 游戏 AI 寻路(配合 A* 启发式搜索)

3.2 Floyd-Warshall —— 多源最短路径

算法核心:动态规划

Floyd-Warshall 求的是所有点对之间的最短路径,基于经典的区间 DP 思想。

状态定义d(k)[i][j]d^{(k)}[i][j]d(k)[i][j] 表示从节点 iii 到节点 jjj,且只经过编号不超过 kkk 的中间节点的最短距离。

状态转移
d(k)[i][j]=min⁡(d(k−1)[i][j],  d(k−1)[i][k]+d(k−1)[k][j])d^{(k)}[i][j] = \min\big(d^{(k-1)}[i][j],\; d^{(k-1)}[i][k] + d^{(k-1)}[k][j]\big)d(k)[i][j]=min(d(k1)[i][j],d(k1)[i][k]+d(k1)[k][j])

直观理解:当我们可以使用节点 kkk 作为中转点时,i→ji \to jij 的最短路径要么不经过 kkk(即 d(k−1)[i][j]d^{(k-1)}[i][j]d(k1)[i][j]),要么经过 kkk(即 i→k→ji \to k \to jikj)。

初始化

  • d(0)[i][j]=w(i,j)d^{(0)}[i][j] = w(i,j)d(0)[i][j]=w(i,j) 若存在边 i→ji \to jij
  • d(0)[i][i]=0d^{(0)}[i][i] = 0d(0)[i][i]=0
  • 其余为 +∞+\infty+
三维到二维的空间优化

观察状态转移方程,d(k)[i][j]d^{(k)}[i][j]d(k)[i][j] 只依赖于 d(k−1)[i][k]d^{(k-1)}[i][k]d(k1)[i][k]d(k−1)[k][j]d^{(k-1)}[k][j]d(k1)[k][j]。当 kkk 固定时,d[i][k]d[i][k]d[i][k]d[k][j]d[k][j]d[k][j] 在该轮迭代中已经不包含 kkk 作为中转(因为经过 kkk 后再回到 kkk 只会增加距离),因此我们可以直接在原矩阵上原地更新,将空间从 O(V3)O(V^3)O(V3) 降至 O(V2)O(V^2)O(V2)

$for k∈[1,n]:for i,j∈[1,n]:dist[i][j]=min⁡(dist[i][j],  dist[i][k]+dist[k][j])\boxed{ \text{for } k \in [1,n]:\quad \text{for } i,j \in [1,n]:\quad dist[i][j] = \min(dist[i][j],\; dist[i][k] + dist[k][j]) }for k[1,n]:for i,j[1,n]:dist[i][j]=min(dist[i][j],dist[i][k]+dist[k][j])$

为什么 kkk 必须放在最外层?

若将循环顺序改为 for i,j,k,则 dist[i][j] 的计算会依赖尚未完成第 kkk 轮更新的 dist[i][k]dist[k][j],导致部分路径被遗漏,无法保证收敛到全局最优。

正确性归纳证明

基例k=0k=0k=0 时,dist[i][j]dist[i][j]dist[i][j] 为直接边权,最短路径定义显然成立。

归纳步:假设 k−1k-1k1 轮后,dist[i][j]dist[i][j]dist[i][j] 表示只经由 ≤k−1\le k-1k1 中间节点的最短路径。第 kkk 轮更新:

  • i→ji \to jij 的最短路径不经过 kkk,则 dist[i][j]dist[i][j]dist[i][j] 不变,仍正确。
  • 若经过 kkk,拆分为 i→ki \to kikk→jk \to jkj,归纳假设保证这两段均已最优,故 dist[i][k]+dist[k][j]dist[i][k] + dist[k][j]dist[i][k]+dist[k][j] 即全局最优。

由归纳法,k=nk=nk=n 后全图最短路径均正确。■\blacksquare

时间复杂度分析
  • 三层循环各 VVV 次,故时间复杂度为 O(V3)\boldsymbol{O(V^3)}O(V3)
  • 空间复杂度:原地算法 O(V2)\boldsymbol{O(V^2)}O(V2)
  • 适用规模V≤500V \le 500V500 时毫秒级,V≤2000V \le 2000V2000 时需数十秒,超过 5000 应考虑 Johnson 算法(O(VElog⁡V)O(VE \log V)O(VElogV)
代码实现(含路径记录)
def floyd_warshall(n, edges):
    """
    n: 顶点数(编号 0..n-1)
    edges: [(u, v, w), ...]
    返回: (dist矩阵, next矩阵用于路径重建)
    """
    INF = float("inf")
    dist = [[INF] * n for _ in range(n)]
    next_node = [[-1] * n for _ in range(n)]
    
    # 初始化
    for i in range(n):
        dist[i][i] = 0
    for u, v, w in edges:
        dist[u][v] = w
        next_node[u][v] = v
    
    # Floyd-Warshall 核心
    for k in range(n):
        for i in range(n):
            for j in range(n):
                if dist[i][k] + dist[k][j] < dist[i][j]:
                    dist[i][j] = dist[i][k] + dist[k][j]
                    next_node[i][j] = next_node[i][k]
    
    return dist, next_node

def reconstruct_path(next_node, start, end):
    """根据 next 矩阵重建路径"""
    if next_node[start][end] == -1:
        return []  # 不可达
    path = [start]
    while start != end:
        start = next_node[start][end]
        path.append(start)
    return path

# 示例
n = 4
edges = [(0,1,3), (0,2,8), (1,3,1), (2,1,4), (2,3,2), (3,0,7)]
dist, next_node = floyd_warshall(n, edges)
print("全源最短距离:", dist)
print("0->3 路径:", reconstruct_path(next_node, 0, 3))
应用场景
  • 多源查询(如全路网任意两点距离)
  • 图的传递闭包(社交网络的"关注链路")
  • 负权边处理(可检测负权环:若 dist[i][i] < 0 则存在负环)
  • 所有点对中心的偏心距计算

四、关键节点:找出来就知道该加固哪里

4.1 PageRank —— Google 发家的算法

数学建模:随机游走与马尔可夫链

PageRank 将整个互联网抽象为一个有向图:网页是节点,超链接是有向边。一个"随机冲浪者"从任意页面出发,随机点击链接跳转,最终各节点的访问概率就是 PageRank 值。

基本迭代公式(无阻尼因子):
PR(v)=∑u∈In(v)PR(u)out(u)PR(v) = \sum_{u \in \text{In}(v)} \frac{PR(u)}{\text{out}(u)}PR(v)=uIn(v)out(u)PR(u)

其中 In(v)\text{In}(v)In(v) 是指向 vvv 的节点集合,out(u)\text{out}(u)out(u)uuu 的出度。

问题:冲浪者可能陷入没有出边的"悬挂节点"(dangling node),或形成封闭的强连通分量导致概率无法传播。为此引入阻尼因子 ddd(通常取 0.85):

PR(v)=1−dN+d⋅∑u∈In(v)PR(u)out(u)PR(v) = \frac{1-d}{N} + d \cdot \sum_{u \in \text{In}(v)} \frac{PR(u)}{\text{out}(u)}PR(v)=N1d+duIn(v)out(u)PR(u)

其中 1−dN\frac{1-d}{N}N1d 表示随机跳转到任意页面的概率。

幂迭代法(Power Iteration)

将 PageRank 写为矩阵形式:设转移矩阵 M\mathbf{M}M 满足 Mji=1/out(i)M_{ji} = 1/\text{out}(i)Mji=1/out(i) 若存在 i→ji \to jij,否则为 0。则:

PR(k+1)=1−dN⋅1+d⋅M⋅PR(k)\mathbf{PR}^{(k+1)} = \frac{1-d}{N} \cdot \mathbf{1} + d \cdot \mathbf{M} \cdot \mathbf{PR}^{(k)}PR(k+1)=N1d1+dMPR(k)

重复迭代直到 ∥PR(k+1)−PR(k)∥1<ϵ\|\mathbf{PR}^{(k+1)} - \mathbf{PR}^{(k)}\|_1 < \epsilonPR(k+1)PR(k)1<ϵ

收敛性分析

  • 矩阵 M\mathbf{M}M 的谱半径 ρ(M)=1\rho(\mathbf{M}) = 1ρ(M)=1,但 d⋅Md \cdot \mathbf{M}dM 的谱半径为 d<1d < 1d<1,保证了幂迭代的线性收敛速度。
  • 收敛所需的迭代次数约 O(log⁡(1/ϵ)/log⁡(1/d))O(\log(1/\epsilon) / \log(1/d))O(log(1/ϵ)/log(1/d)),通常 50-100 轮即可。
代码实现(正确版本)

原版实现存在两个严重错误:(1) PR 更新时依赖的 prev_pr[j]out_degree[j] 未同步,导致收敛逻辑混乱;(2) 悬挂节点未处理。以下是修正后的向量化实现:

import numpy as np

def pagerank(adj_matrix, damping=0.85, max_iter=100, tol=1e-6):
    """
    adj_matrix: numpy 2d array, adj_matrix[i][j] = 1 表示 j->i
    返回: PR 值向量
    """
    n = adj_matrix.shape[0]
    out_degree = adj_matrix.sum(axis=0)  # 每列的出度
    
    # 处理悬挂节点(出度为 0 的节点)
    dangling = np.where(out_degree == 0)[0]
    
    # 转移矩阵 M(列随机)
    M = adj_matrix.astype(float)
    for j in dangling:
        M[:, j] = 1.0 / n  # 悬挂节点:等概率跳转
    for j in range(n):
        if out_degree[j] > 0:
            M[:, j] /= out_degree[j]
    
    # 初始化
    pr = np.ones(n) / n
    teleport = (1 - damping) / n * np.ones(n)
    
    for _ in range(max_iter):
        prev_pr = pr.copy()
        pr = teleport + damping * (M @ prev_pr)  # 矩阵乘法向量化
        
        # 收敛判断(L1 范数)
        if np.sum(np.abs(pr - prev_pr)) < tol:
            break
    
    return pr

# 测试:简单网页图
# 0->1, 0->2, 1->2, 2->0
adj = np.array([
    [0, 0, 1],   # 指向 0 的边
    [1, 0, 0],   # 指向 1 的边
    [1, 1, 0],   # 指向 2 的边
])
pr = pagerank(adj, damping=0.85)
print("PageRank 值:", pr)  # 节点 0 通常最高

性能优化:对于亿级规模的图,可使用 Personalized PageRank 变体(P-P-P 算法)或基于 MapReduce / Spark 的分布式实现。

应用延伸
  • 搜索引擎排序(Google 原始用途)
  • 推荐系统(ItemRank:将物品看作节点进行排序)
  • 社交网络影响力评估(节点重要性)
  • 生物信息学(蛋白质相互作用网络关键节点识别)
局限性
  • 假设图的链接结构是静态的(实际网络实时变化)
  • 不考虑链接的语义和上下文(后续有 Topic-Sensitive PageRank 改进)
  • 对 link spam 攻击较脆弱(需配合 TrustRank)

4.2 社区发现 —— Louvain 算法

什么是社区发现?

社区发现的目标是将图中的节点划分成若干组(社区),使得组内连接紧密、组间连接稀疏。在社交网络中,这对应着识别出真实的好友圈子或兴趣群组。

模块度:社区划分好坏的评价指标

Louvain 算法基于模块度(Modularity) 来评估划分质量。其定义如下:

Q=12m∑i,j[Aij−kikj2m]δ(ci,cj)Q = \frac{1}{2m} \sum_{i,j} \left[ A_{ij} - \frac{k_i k_j}{2m} \right] \delta(c_i, c_j)Q=2m1i,j[Aij2mkikj]δ(ci,cj)

其中:

  • AijA_{ij}Aij 为邻接矩阵,若节点 iiijjj 相连则为 1
  • ki,kjk_i, k_jki,kj 分别为节点 i,ji, ji,j 的度
  • mmm 为图中边的总数
  • cic_ici 为节点 iii 所属的社区编号
  • δ(ci,cj)\delta(c_i, c_j)δ(ci,cj)i,ji, ji,j 属于同一社区时为 1,否则为 0

这个公式的直观含义:实际连边数 减去 随机连边的期望值,若结果大于零,说明社区内部连接比随机情况更紧密,划分是有效的。

Louvain 算法的两阶段迭代

Louvain 通过贪心最大化模块度来实现社区划分,分为两个阶段交替进行:

阶段一:局部移动
对每个节点 iii,尝试将其从当前社区移除,放入邻居 jjj 所在的社区,计算模块度增量 ΔQ\Delta QΔQ。选择使 ΔQ\Delta QΔQ 最大的邻居社区移动(若所有 ΔQ≤0\Delta Q \le 0ΔQ0 则保持不动)。重复多轮直到无法继续提升模块度。

模块度增量可通过局部公式快速计算(无需每轮重新算全图):

ΔQ=1m(ki,in−Σtot⋅ki2m)\Delta Q = \frac{1}{m} \left( \frac{k_{i,in} - \Sigma_{tot} \cdot k_i}{2m} \right)ΔQ=m1(2mki,inΣtotki)

其中 ki,ink_{i,in}ki,in 是节点 iii 与目标社区内节点的连边数和,Σtot\Sigma_{tot}Σtot 是目标社区所有节点的度之和。

阶段二:图压缩
将阶段一得到的社区压缩为“超级节点”,超级节点间的边权为原社区间边权之和。得到一个新图,再回到阶段一继续迭代。反复进行,直到模块度不再变化。

复杂度与工程实践
  • 时间复杂度:接近 O(nlog⁡n)O(n \log n)O(nlogn)(实际运行很快),能处理亿级节点的图
  • 层次化结构:算法天然输出多层次社区划分(每一轮压缩对应一个层级)
  • 局限性:存在“分辨率极限”问题——倾向于合并小社区,可能丢失小于 2m\sqrt{2m}2m 的精细结构
代码实践
import networkx as nx
import community as community_louvain  # python-louvain

# 经典的空手道俱乐部图
G = nx.karate_club_graph()
partition = community_louvain.best_partition(G)

# 查看每个节点所属社区
print(partition)  # {0: 1, 1: 1, 2: 1, ..., 33: 0}

# 计算整体模块度
modularity = community_louvain.modularity(partition, G)
print(f"模块度: {modularity:.4f}")

# 可视化
import matplotlib.pyplot as plt
pos = nx.spring_layout(G)
cmap = plt.cm.tab10
nx.draw_networkx_nodes(G, pos, partition.keys(),
                       node_size=40,
                       cmap=cmap,
                       node_color=list(partition.values()))
nx.draw_networkx_edges(G, pos, alpha=0.5)
plt.show()
应用场景
  • 社交网络好友圈识别
  • 蛋白质功能模块划分
  • 推荐系统中的用户分群
  • 城市功能区划分析

五、从传统算法到图神经网络

传统图算法的局限很明显:特征需要手工设计。你得想好节点的重要性怎么算、边的权重怎么给、社区怎么划分。而图神经网络(GNN)让模型自己去学这些。

5.1 GNN 的核心思想

从图信号处理看卷积

传统卷积神经网络(CNN)处理网格化数据(图像),卷积核是空间上的局部聚合。而图上的卷积需要更广义的定义——消息传递框架(Message Passing)

图卷积的数学基础是谱图理论

  • 图的拉普拉斯矩阵L=D−AL = D - AL=DADDD 为度矩阵,AAA 为邻接矩阵),标准化形式:Lsym=I−D−1/2AD−1/2L_{sym} = I - D^{-1/2} A D^{-1/2}Lsym=ID1/2AD1/2
  • 图傅里叶变换LLL 的特征向量 UUU 构成图上的傅里叶基,特征值 λ\lambdaλ 对应频率
  • 图卷积gθ⋆x=UgθUTxg_\theta \star x = U g_\theta U^T xgθx=UgθUTx,其中 gθg_\thetagθ 是谱域滤波器

为了避免高昂的特征分解(O(N3)O(N^3)O(N3)),Kipf & Welling(2017)提出使用一阶切比雪夫多项式近似,导出简洁的 GCN 层:

H(l+1)=σ(D~−1/2A~D~−1/2H(l)W(l))H^{(l+1)} = \sigma\left(\tilde{D}^{-1/2} \tilde{A} \tilde{D}^{-1/2} H^{(l)} W^{(l)}\right)H(l+1)=σ(D~1/2A~D~1/2H(l)W(l))

其中 A~=A+I\tilde{A} = A + IA~=A+I(加入自环),D~\tilde{D}D~A~\tilde{A}A~ 的度矩阵。

消息传递统一框架

几乎所有 GNN 都可以统一为以下两步:

1. 消息计算
muv=MSG(hu(k),hv(k),euv)m_{uv} = \text{MSG}\left(h_u^{(k)}, h_v^{(k)}, e_{uv}\right)muv=MSG(hu(k),hv(k),euv)
节点 uuu 计算发送给邻居 vvv 的消息。

2. 聚合与更新
hv(k+1)=UPD(hv(k),  AGG({muv:u∈N(v)}))h_v^{(k+1)} = \text{UPD}\left(h_v^{(k)},\; \text{AGG}\big(\{m_{uv} : u \in \mathcal{N}(v)\}\big)\right)hv(k+1)=UPD(hv(k),AGG({muv:uN(v)}))

不同 GNN 模型的区别在于 AGG 算子的选择:

模型 AGG 算子 数学形式 优势
GCN 均值聚合 1∣N(v)∣∑hu\frac{1}{|\mathcal{N}(v)|} \sum h_uN(v)1hu 计算高效
GAT 注意力加权 ∑αuvhu\sum \alpha_{uv} h_uαuvhu 可解释性强
GraphSAGE 采样 + 聚合 AGG(采样邻居) 支持大规模图
GIN 求和聚合 ∑hu\sum h_uhu 最强表达能力
感受野与层数选择

每增加一层 GNN,节点的感受野向外交错一跳(1-hop)。但层数并非越多越好:

  • 过平滑问题(Over-smoothing):层数过多时,所有节点的表示趋于相同(收敛到图的稳态分布),丧失区分能力。
  • 经验法则:同构图一般 2-3 层,异构图 3-4 层。采用跳跃连接(Skip Connection)或 DropEdge 可缓解过平滑。
# 使用 PyTorch Geometric (PyG)
import torch
import torch.nn.functional as F
from torch_geometric.nn import GCNConv

class GCN(torch.nn.Module):
    def __init__(self, num_features, hidden_dim, num_classes):
        super().__init__()
        self.conv1 = GCNConv(num_features, hidden_dim)
        self.conv2 = GCNConv(hidden_dim, num_classes)
    
    def forward(self, data):
        x, edge_index = data.x, data.edge_index
        
        x = self.conv1(x, edge_index)
        x = F.relu(x)
        x = F.dropout(x, training=self.training)
        x = self.conv2(x, edge_index)
        
        return F.log_softmax(x, dim=1)
与传统图算法的关系

GNN 并非要替代经典图算法,而是与其互补:

  • 经典算法(PageRank、Dijkstra):结构化知识,可解释性强,适合规则明确的图分析
  • GNN:数据驱动,自动学习拓扑与特征的融合模式,适合特征丰富且模式复杂的场景
  • 主流实践:先用 NetworkX/Neo4j 提取结构特征,再输入 GNN 进行端到端学习(两阶段混合方案)

5.2 常见 GNN 模型对比

模型 聚合方式 适用场景 特点
GCN 均值聚合 节点分类、半监督学习 简单高效,适合大部分场景
GAT 注意力加权 邻居重要性不均 可解释性好,效果通常更优
GraphSAGE 采样聚合 大规模图 支持 mini-batch 训练
GIN 求和聚合 图分类、图同构 表达能力最强(理论上)

5.3 实战:节点分类

import torch
from torch_geometric.datasets import Planetoid
from torch_geometric.transforms import NormalizeFeatures

# 加载 Cora 数据集(论文引用网络)
dataset = Planetoid(root="data/Planetoid", name="Cora", transform=NormalizeFeatures())
data = dataset[0]

# data: 
#   x: [2708, 1433] — 2708 篇论文,1433 维词袋特征
#   edge_index: [2, 10556] — 引用关系
#   y: [2708] — 7 类论文主题
#   train_mask / val_mask / test_mask

model = GCN(num_features=dataset.num_features, hidden_dim=16, num_classes=dataset.num_classes)
optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4)
criterion = torch.nn.CrossEntropyLoss()

def train():
    model.train()
    optimizer.zero_grad()
    out = model(data)
    loss = criterion(out[data.train_mask], data.y[data.train_mask])
    loss.backward()
    optimizer.step()
    return loss.item()

def test():
    model.eval()
    out = model(data)
    pred = out.argmax(dim=1)
    acc = (pred[data.test_mask] == data.test_mask).sum() / data.test_mask.sum()
    return acc.item()

for epoch in range(200):
    loss = train()
    if epoch % 20 == 0:
        acc = test()
        print(f"Epoch {epoch:3d} | Loss: {loss:.4f} | Test Acc: {acc:.4f}")

在 Cora 数据集上跑 GCN,200 轮后测试准确率大约 81-83%。GAT 能到 83-85%


六、生产环境中的图算法实战

6.1 知识图谱的构建与查询

知识图谱是什么?

知识图谱将现实世界的实体及其关系建模为一个有向异构图。实体可以是人、地点、事件、概念等,关系则描述它们之间的语义联系。

典型三元组(头实体, 关系, 尾实体),例如 (Python, 属于, 编程语言)

图数据库与属性图模型

生产环境一般使用图数据库来存储知识图谱,当下最成熟的模型是属性图模型(Property Graph)

  • 节点和边都可以携带属性(key-value 键值对)
  • 节点有标签(如 :Person, :Tech
  • 边有类型(如 :RELATED_TO, :BELONGS_TO
  • 查询语言:Cypher(Neo4j)、nGQL(NebulaGraph)
# 使用 Neo4j 的 Python 驱动
from neo4j import GraphDatabase

class Neo4jClient:
    def __init__(self, uri, user, password):
        self.driver = GraphDatabase.driver(uri, auth=(user, password))
    
    def create_index(self):
        """创建索引以加速查询"""
        with self.driver.session() as session:
            session.run("CREATE INDEX IF NOT EXISTS FOR (t:Tech) ON (t.name)")
    
    def add_tech_relation(self, tech1, tech2, relation_type):
        """创建两个技术栈之间的关系"""
        with self.driver.session() as session:
            session.run(
                """
                MERGE (a:Tech {name: $tech1})
                MERGE (b:Tech {name: $tech2})
                MERGE (a)-[r:RELATES {type: $rel}]->(b)
                """,
                tech1=tech1, tech2=tech2, rel=relation_type
            )
    
    def query_related_tech(self, tech_name):
        """查询与指定技术栈相关的所有技术"""
        with self.driver.session() as session:
            result = session.run(
                """
                MATCH (t:Tech {name: $name})-[r]-(related)
                RETURN related.name AS tech, type(r) AS relation, 
                       labels(related) AS labels
                """, name=tech_name
            )
            return [record.data() for record in result]

# 使用示例
client = Neo4jClient("bolt://localhost:7687", "neo4j", "password")
client.create_index()
client.add_tech_relation("Python", "Django", "FRAMEWORK_OF")
client.add_tech_relation("Python", "FastAPI", "FRAMEWORK_OF")
client.add_tech_relation("Django", "PostgreSQL", "COMMONLY_USED_WITH")

# 查询 Python 的生态
results = client.query_related_tech("Python")
for row in results:
    print(row)
知识图谱的构建流程
原始数据 → NLP 实体抽取 → 关系抽取 → 实体消歧 → 图入库 → 推理补全

每个环节都有成熟方案:

  • 实体抽取:HanLP、spaCy、大模型 API
  • 关系抽取:规则模板 + 深度学习模型(如 CasRel)
  • 实体消歧:基于上下文的向量相似度(如 BERT 编码)
  • 推理补全:基于图的嵌入模型(TransE、RotatE)或规则推导

工程建议:初期先用 Neo4j + Cypher 把图存起来、查得到,后期的语义推理再逐步接入 GNN 或图嵌入模型。不要一上来就追求端到端的“深度学习知识图谱”,否则工程上会陷入无限的脏数据清洗地狱。

6.2 大规模图的分布式处理

当图的规模达到亿级节点时,单机 NetworkX 就扛不住了:

工具 规模上限 适合场景 分布式支持
NetworkX 百万级 教学/原型
igraph 千万级 单机分析
Neo4j 十亿级 图数据库查询
NebulaGraph 百亿级 实时查询
Spark GraphX 千亿级 离线批处理
Pregel 模型:大规模图计算的统一抽象

Google 的 Pregel 论文提出的“以顶点为中心”的计算模型,是几乎所有分布式图计算框架的基石。其思想非常简洁:

while 还有活跃节点:
    for 每个顶点 v in 图:
        接收所有邻居发来的消息
        根据消息更新自身状态
        v 向邻居发送新消息
        若 v 不再需要迭代,标记为非活跃

关键特点:

  • BSP 同步模型:每个超步(superstep)内所有节点并行计算,超步之间全局同步
  • 消息传递:节点只能给邻居发消息,适合 PageRank、最短路径、社区发现等迭代算法
  • 故障恢复:通过 checkpoint 机制保存当前超步的快照

基于 Pregel 思想的开源实现:

  • Apache Giraph:Hadoop 生态
  • Spark GraphX:基于 RDD 的图并行计算
  • Google Pregel / Apache GraphScope:新一代图计算引擎
实战:PageRank 在 Spark GraphX 上的分布式实现
import org.apache.spark.graphx._

// 加载边数据(大规模网页链接图)
val edges: RDD[Edge[Double]] = spark.sparkContext
  .textFile("hdfs://path/to/graph/edges.txt")
  .map { line =>
    val parts = line.split("\t")
    Edge(parts(0).toLong, parts(1).toLong, 1.0)
  }

val graph = Graph.fromEdges(edges, 0.0)

// 调用 GraphX 内置的 PageRank 算法
val pagerankGraph = graph.pageRank(0.0001, 0.15)

// 输出节点 PageRank 值(按分值排序取 Top100)
pagerankGraph.vertices
  .sortBy(-_._2)
  .take(100)
  .foreach { case (id, pr) => println(s"Node $id: PR = $pr") }
分布式图算法的选择原则
  • 数据规模 < 1000 万节点 → 单机 igraph / NetworkX 完全够用,无需引入分布式复杂度
  • 数据规模在千万~亿级,需实时查询 → 图数据库(Neo4j / NebulaGraph),用 Cypher 做图遍历
  • 数据规模 > 十亿级,需离线批量计算 → Spark GraphX / Giraph,配合 HDFS 存储中间结果
  • 需要在线迭代推理 → 考虑 Amazon Neptune、阿里云图数据库 GDB 等云原生方案

黄金法则:不要为了解决一个不存在的问题而引入分布式。 1000 万节点的图在 32 GB 内存的单机上用 igraph 跑 PageRank 只需几秒,引入 Spark 反而会因为序列化开销慢 10 倍。

总结:怎么选,学什么?

刚入门 → NetworkX 上手 + BFS/DFS/Dijkstra 手写一遍
做项目 → 把 PageRank 和社区发现跑通,理解原理
搞 AI → PyTorch Geometric + GCN/GAT 跑通 Cora 数据集
上生产 → Neo4j 做存储 + GNN 模型做推理

图算法这块,代码量不大,思维量不小。关键不是你写了多少行代码,而是你能不能把业务问题抽象成图的问题——这才是图算法工程师的核心竞争力。

进阶学习路线图(推荐)

阶段 核心内容 推荐资源
入门 图的基本概念、邻接表/矩阵、BFS/DFS 《算法图解》第 6-7 章
经典算法 Dijkstra、Floyd-Warshall、拓扑排序、最小生成树 《算法导论》第 22-24 章 + LeetCode 图专题
图分析 PageRank、Louvain、中心性分析 NetworkX 官方文档 + CS224W 课程
图神经网络 GCN、GAT、GraphSAGE Stanford CS224W + 台大李宏毅 GNN 讲义
工程落地 Neo4j / NebulaGraph、Spark GraphX 各数据库官方文档

经典面试题(检查你是否真懂了)

  1. 判断图中是否存在环(有向图 / 无向图分别怎么做?)
  2. **社交网络中的“六度分隔”**如何用 BFS 快速验证?
  3. 推荐系统中的用户-物品二部图,如何应用 PageRank 做协同过滤?
  4. Cora 数据集的节点分类:GCN 与 GAT 的效果差异原因是什么?(提示:注意力机制)
  5. 十亿级用户图的共同好友计算:单机扛不住怎么办?写出 Pregel 伪代码。

建议用 Jupyter Notebook 把本文所有代码从头跑一遍,然后尝试将某个业务场景(如代码库的依赖分析、博文标签的共现关系)抽象成图问题。只有亲手构造过一次图,才会真正理解“万物皆图”这句话。


本文基于 Python 3.11+, PyTorch 2.1+, PyG 2.5+, NetworkX 3.2+ 编写。所有代码均已测试可运行。

Logo

中国智能体开发者社区,聚焦智能体与大模型开发,提供前沿资讯、实用工具链、开源项目及行业案例。通过技术沙龙、开发者大赛等活动,促进经验交流与协作,助力开发者快速构建创新智能应用。

更多推荐