图论中邻接矩阵和邻接表详解


1. 图(Graph)的基本概念

一个图 ( G = (V, E) ) 包含:

  • 节点 / 顶点(Vertex):( V )
  • 边(Edge):( E )

分为:

  • 无向图
  • 有向图
  • 带权图(权重通常为距离、概率等)

2. 邻接矩阵(Adjacency Matrix)


2.1 定义

邻接矩阵 (A) 对于有 (n) 个顶点的图是一个 (n\times n) 的二维数组。常见变体:

  • 无向无权图
    (Aij = 1) 表示存在边,(0) 表示无边。对称矩阵:(Aij = Aji)。
  • 有向无权图
    (Aij = 1) 表示从 (i) 指向 (j) 的有向边(不一定对称)。
  • 带权图 (有向或无向):
    (Aij = w_{ij})(例如边长、代价、相似度)。无边处通常用特殊值表示(0、INF、或 NaN,取决于语义)。
  • 自环:如果允许自环,则对角线 (Aii) 可为 1 或权值。
  • 稀疏矩阵 vs 密集矩阵:邻接矩阵天然是密集表示;当 (m \ll n^2)(稀疏图)时它并非空间友好。

无边的表示(实务)

  • 带权最常用:A[i][j] = +Inf(用 std::numeric_limits<double>::infinity()),这样在最短路算法中方便跳过;也可用一个布尔 mask 表示存在性与否。
  • 无权场景下可用 0/1,但当 0 可能是合法权重时需小心。

2.2 图示例

举例扩展:带权有向图的邻接矩阵示例(设无边用 INF):

复制代码
   0   1   2   3
0  0  2.5 INF INF
1 INF 0  1.0  INF
2 INF INF 0  3.2
3 INF INF INF 0

该矩阵直接可用于 Floyd--Warshall (全源最短路),矩阵自底向上更新 dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j])


2.3 内存复杂度

空间复杂度:(O(n^2))。

  • 若使用 double 存权值:内存约 8 * n^2 bytes。
    例如 (n=10000) 时:8 * 1e8 = 800MB(仅矩阵)。
  • 若使用 bool 或位图(bitset):可以压缩到约 n^2 / 8 bytes------仍会很快变大。

工程建议

  • (n \lesssim 10^4) 且图稠密:可用密集邻接矩阵。
  • (n) 很大且稀疏(如 SLAM 的变量图通常 (m = O(n))):不要用邻接矩阵,应使用邻接表或稀疏矩阵(CSR/CSC)。

2.4 典型操作复杂度

  • 检查边(i,j) :O(1) ------ 直接访问 A[i][j]
  • 遍历 i 的所有邻居:O(n) ------ 扫一整行。稀疏情况下这是低效的(很多是0/INF)。
  • 插入/删除边:O(1) ------ 直接写入/置零。
  • 增添节点:需要重新分配并复制整个 (n\times n) 矩阵:O(n^2) ------ 非常昂贵。
  • 矩阵乘法 / 幂 :用于计数长度为 k 的路径(A^k)等,可用线性代数库(复杂度 (O(n^3)) 或更好并行实现)。

2.5 优点

  • O(1) 边查询:适合需要频繁判断顶点对是否相连的场景(例如动态验证全连通性或稠密相似度矩阵)。
  • 适合线性代数处理:可以直接用 BLAS / GPU(cuBLAS / cuSPARSE)进行并行运算(矩阵乘法、特征分解)。
  • 简单且常用于理论分析:很多代数图理论(图谱、拉普拉斯矩阵)都基于邻接矩阵或其变体(度矩阵、拉普拉斯)。

2.6 缺点

  • 空间没处可逃:稀疏图会浪费大量内存。
  • 遍历邻居代价高:即使实际度很小,也要扫描整个行。
  • 动态调整困难:添加/删除大量节点/重编号代价高。
  • 缓存局部性:虽然矩阵按行存储在内存中,连续访问行很快,但若算法需要随机访问多行/列会出现缓存不友好问题。

2.7 C++ 示例

1) 固定大小的静态数组

适合小图、最高性能需求,但不灵活:

cpp 复制代码
const int N = 100;
std::array<std::array<int, N>, N> adj{}; // 或静态 int adj[N][N]
adj[u][v] = 1;

注意std::vector<std::vector<int>> 会额外分配很多小块,导致性能下降;优先使用一维连续数组模拟二维(vector<T> data(n*n); data[i*n + j])以获得更好缓存。

2) 动态大小

cpp 复制代码
struct AdjMatrix {
  size_t n;
  std::vector<double> data; // 使用 double 表示权值(无边用 INF)
  static constexpr double INF = std::numeric_limits<double>::infinity();

  AdjMatrix(size_t n_) : n(n_), data(n_*n_, INF) {
    for(size_t i=0;i<n;i++) data[i*n + i] = 0.0;
  }

  inline double& at(size_t i, size_t j) { return data[i*n + j]; }
  inline bool hasEdge(size_t i,size_t j) { return data[i*n + j] != INF; }

  std::vector<size_t> neighbors(size_t i) {
    std::vector<size_t> nb;
    for(size_t j=0;j<n;j++) if(data[i*n + j] != INF && i!=j) nb.push_back(j);
    return nb;
  }
};

优势:一维连续存储,友好缓存;方便序列化与 GPU 拷贝。

注意vector<bool> 不建议用于位图(语义/效率问题),若需要位图请用 std::vector<uint64_t>boost::dynamic_bitset

3) 使用位矩阵(节省空间、加速位运算)

当图是无权且只需存在性测试,可以用位矩阵(每行为 bitset),支持快速交并运算(例如计算公共邻居,Jaccard 相似度):

cpp 复制代码
#include <bitset> // 若 n 编译时已知
std::vector<std::vector<uint64_t>> bit_rows; // 行压缩为 uint64_t 单元

// 测试公共邻居数
uint64_t count_common(size_t i, size_t j) {
  uint64_t sum = 0;
  for(k...) sum += __popcnt64(rowi[k] & rowj[k]);
  return sum;
}

位运算非常适合 GPU/向量化加速与大量相似度计算(例如 loop closure 相似度矩阵)。

4) 用线性代数库(如 Eigen)

带权图且需要做谱分解 / 特征值运算时,使用 Eigen / Armadillo:

cpp 复制代码
#include <Eigen/Dense>
Eigen::MatrixXd A(n, n);
// fill A
Eigen::SelfAdjointEigenSolver<Eigen::MatrixXd> solver(A);
auto evals = solver.eigenvalues();

注意 :Eigen 的 MatrixXd 是 row-major 或 col-major 可配置;与你内存布局要匹配以免额外拷贝。


附:邻接矩阵的常见优化与变体

  1. 只存上(三角)或下三角(对称无向图):节省一半空间,但访问需做索引变换。
  2. 压缩行(CSR / CSC) :虽然这本质是稀疏矩阵格式,若邻接矩阵非常稀疏,可以把邻接矩阵视作稀疏矩阵存储,利用 row_ptr, col_idx, val 来节省空间并加快遍历邻居(与邻接表类似但更便于数值运算)。
  3. 位图 + 权值分离:存在性用位图,权值另存(仅保存实际边)。对查询速度与存储权衡有利。
  4. Block-sparse / Tiled:把大矩阵分成块,适合 GPU / 分布式场景(按块分配可加速并行乘法与存取)。

在 SLAM / 图优化 中的实践提示

  • 因子图(GTSAM)通常稀疏 → 使用邻接表或稀疏矩阵(CSR)更合适。邻接矩阵只在特定阶段(例如全连接相似度计算、BoW 两两相似计算)临时使用。
  • 回环候选相似度(全配对) :若需要两两相似度矩阵(例如 10k 帧),可用 位矩阵 + GPU 并行 计算快速过滤候选,再细化用稀疏结构。
  • 图谱方法(谱聚类、拉普拉斯特征):需要密集矩阵或稀疏线性代数库支持(Eigen / Spectra / ARPACK)。
  • 网格地图(occupancy grid):本质上是一个稠密的二维邻接矩阵(每个 cell 与邻居相连),这里邻接矩阵/邻接格点表示都常用,且非常适合 GPU。

常见误区与陷阱

  • vector<vector<T>> 表示邻接矩阵会产生大量小内存分配,影响性能;优先用 vector<T> 一维数组映射 2D 索引。
  • vector<bool> 作为位图时语义/性能会受损(vector<bool> 为特殊化实现),建议用 std::bitset(编译期大小)或 boost::dynamic_bitset,或自己用 vector<uint64_t>
  • 混合使用多处 Eigen 版本或旧 Eigen 可能导致 CUDA 编译失败(你之前遇到的问题),务必保证 include 顺序与版本一致。
  • 若算法频繁增删节点,应避免邻接矩阵。

实用算法示例(邻接矩阵场景优先)

  1. Floyd--Warshall(全源最短路):直接在邻接矩阵上三重循环 O(n^3)。
  2. 路径计数(A^k):矩阵幂可以求长度恰为 k 的路径数量,可用快速幂或并行矩阵乘法。
  3. 谱聚类 / 特征值分解:从邻接矩阵构造度矩阵 (D) 与拉普拉斯 (L = D - A),然后求前 k 个特征向量。
  4. 快速公共邻居 / 相似度:若用位矩阵,公共邻居数可通过位与 + popcount 迅速计算;适用于 loop closure 提名。

3. 邻接表(Adjacency List)


3.1 定义

邻接表为每个顶点维护一个容器(vectorlistdeque、甚至链表索引数组),存储该顶点的所有邻居(以及可选的边属性,如权重、时间戳、信息矩阵等)。

常见表示:

  • vector<vector<int>>:最常用,连续内存,缓存友好。
  • vector<vector<pair<int,Weight>>>:带权图,pair<to, weight>
  • Forward-star / CSR-like :用三数组 head[], to[], next[]offset[], to[],避免大量小分配,内存紧凑,适合静态图/高性能场景。
  • vector<unordered_set<int>>vector<robin_hood_set>:用于需要 O(1) 边查找且允许动态增删。

注意:邻接表按顶点维护"出边列表"(directed)或"邻居列表"(undirected,存双向或半存储)。


3.2 图示例

同样图 0--1--2--3:

  • vector<vector<int>> adj 表示:

    adj[0] = {1}
    adj[1] = {0,2}
    adj[2] = {1,3}
    adj[3] = {2}

  • 带权例子(vector<vector<pair<int,double>>>):

    adj[1] = { {0,0.8}, {2,1.2} }

  • Forward-star(数组表示)示意:

    head[0] = 0; head[1]=1; head[2]=3; head[3]=5; head[4]=6 (head[n]=m)
    to = [1, 0,2, 1,3, 2]
    offset method: neighbors of u are to[offset[u] .. offset[u+1]-1]


3.3 内存复杂度

空间复杂度:(O(n + m)),更精确:

  • 使用 vector<vector<int>>:节点数组 n + 所有邻节点存储 2m(无向双向存储)元素 + 每个内层 vector 的元数据(约 3 pointers 每个);
  • 使用 CSR/offset:offset 长度 n+1to 长度 m(或 2m),极佳压缩比;

工程量化示例:

  • (n=10^6, m=5\times10^6)(稠密度低):

    • vector<vector<int>> 可能因每个 vector 分配开销显著;
    • CSR 存储 offset + to 仅需 ~ 8*(n+1) + 4*m bytes(假设 4 字节 int),非常节省。

3.4 操作复杂度

操作 邻接表(vector 列表)
判断边 (i,j) O(k)(k = deg(i)),若用 hash 集合则 O(1) 平均
遍历邻居 O(k)
插入边 平均 O(1) (push_back,若内存扩容则摊销)
删除边 O(k)(若不知道位置),可用 swap-erase 达到 O(1)(但破坏顺序)
增加节点 O(1)(adj.push_back({})
静态构建(一次性填充) O(m)(如果预分配)

细节:

  • 删除具体边:若保持邻接顺序,可 erase(iterator),为 O(k);若不在意顺序,可 swappop_back() 实现 O(1)。
  • 快速判断边存在性:在 adj[u] 使用 unordered_set 或排序后用二分查找(O(log k))。

3.5 优点

  • 节省空间:对稀疏图最优。
  • 遍历邻居高效:遍历速度与实际邻居数成正比。
  • 易动态修改:添加节点/边、增删动态方便。
  • 适配多种边属性:可直接在邻居项中加权值、时间戳、covariance 等结构体字段,便于 SLAM 中记录多模态信息(例如 loop confidence、information matrix)。
  • 与稀疏线性代数相配:生成稀疏矩阵(CSR)容易,用于稀疏优化。

3.6 缺点

  • 查边非 O(1),除非使用额外结构(hash)。
  • 内存碎片与 allocator 问题 :大量小 vector 分配会导致内存碎片、性能下降 ------ 在大规模工程中推荐使用 reserve 或统一内存池。
  • 不适合非常稠密图:若边接近 (n^2),邻接表会有极多小数组,CSR/矩阵更合适。
  • 并发写入复杂:并发 add/remove 需要锁或原子操作,设计需谨慎。

3.7 C++ 示例与工程实现变体

下面给出多个工程可用的数据结构实现,以及并发 / 高性能 / 序列化 / 转换代码片段。

A. 最简单(开发与教学用)

cpp 复制代码
int n = 100;
std::vector<std::vector<int>> adj(n);

// add undirected edge
void add_edge(int u, int v) {
  adj[u].push_back(v);
  adj[v].push_back(u);
}

// iterate
for (int v : adj[u]) {
  // ...
}

问题:每个 push_back 可能会引起 reallocation,建议 reserve


B. 带权、带属性(常用 SLAM)

cpp 复制代码
struct Edge {
  int to;
  double weight;
  uint64_t timestamp;
  // optional: information matrix index or small fixed-size array
};

std::vector<std::vector<Edge>> adj;

适合在每条边上存储 covariance/information/score,直接用于 loop-closure 筛选和后端因子构造。


C. 前置分配 + swap-erase 删除(高性能)

cpp 复制代码
// reserve neighbor lists
adj[u].reserve(expected_deg[u]);

// O(1) remove (unordered)
void remove_edge_unordered(int u, int v) {
  auto &list = adj[u];
  for (size_t i = 0; i < list.size(); ++i) {
    if (list[i] == v) {
      list[i] = list.back();
      list.pop_back();
      break;
    }
  }
}

优点:删除 O(1),代价是破坏邻居顺序(通常可接受)。


D. 使用 unordered_set 实现 O(1) 存在性检查

cpp 复制代码
std::vector<robin_hood::unordered_flat_set<int>> adj_set; // 或 std::unordered_set

bool has_edge(int u, int v) {
  return adj_set[u].find(v) != adj_set[u].end();
}

void add_edge(int u, int v) {
  adj_set[u].insert(v);
  adj_set[v].insert(u);
}

权衡:更高内存开销,但查询快。适合需要频繁查边(而非遍历邻居)的场景(如动态约束冲突检测)。


E. CSR / Offset 表示(静态图或批量构建首选)

构建步骤:

  1. 统计每个节点度 deg[u]
  2. offset[0]=0; offset[i+1]=offset[i]+deg[i]
  3. 填充 to[offset[u] .. offset[u+1]-1]

代码片段(构建):

cpp 复制代码
std::vector<int> deg(n,0);
for(auto &e: edges) {
  deg[e.u]++; deg[e.v]++;
}
std::vector<int> offset(n+1);
for(int i=0;i<n;i++) offset[i+1]=offset[i]+deg[i];
std::vector<int> to(offset.back());
std::vector<int> cur = offset;
for(auto &e: edges) {
  to[cur[e.u]++] = e.v;
  to[cur[e.v]++] = e.u;
}

优点:内存紧凑、遍历邻居更快(连续内存)、方便并行和 GPU 拷贝。缺点:不支持高效随机增删。


F. Forward-star(链式数组,节省指针)

常见于竞赛/嵌入式:

cpp 复制代码
const int MAXM = ...;
int head[N], to[MAXM], next[MAXM], cnt=0;

void add_edge(int u,int v) {
  to[++cnt] = v; next[cnt] = head[u]; head[u] = cnt;
}

优点:低开销,适合静态或构建一次后多查询场景。缺点:删除复杂。


G. 并发场景(多线程读/写)

  • 读多写少 :读无需锁,写时对单个 adj[u] 使用 mutex 或原子操作。
  • 高并发写入:使用分段锁(per-vertex mutex)或 lock-free structures(复杂)。
  • 构建阶段并行:用线程局部 buffer 收集边,最后合并到主结构(避免锁竞争)。

示例(per-vertex mutex):

cpp 复制代码
std::vector<std::mutex> vertex_mutex(n);

void thread_safe_add_edge(int u,int v) {
  std::scoped_lock lock(vertex_mutex[u], vertex_mutex[v]);
  adj[u].push_back(v);
  adj[v].push_back(u);
}

H. GPU / 大规模并行处理

  • 把邻接表转成 CSR (offset + to arrays) ,上传到 GPU;在 kernel 中用 offset[u]..offset[u+1] 范围遍历;
  • 位图表示(bitset)也适合 GPU 用于快速并行相似度计算(popcount)。
  • 注意 host->device 的内存对齐与数据类型大小(use 32-bit indices if possible)。

I. 序列化 / 存储(磁盘 / 网络)

  • CSR 更适合序列化(写出 offsets, to arrays)。
  • 建议写版本号、n、m、offset\[\], to\[\], weights\[\],按二进制写入加速。
  • 对于非常大的图(>RAM),使用外存图数据库(GraphChi/Neo4j)或 memory-mapped files。

J. 转换:邻接矩阵 ↔ 邻接表

  • 矩阵 -> 邻接表:对每行扫描 O(n^2);稀疏时代价高。
  • 邻接表 -> CSR:O(n+m) 通过前述 degree->offset 构建步骤。

并发/性能优化建议

  1. 预分配(reserve) :如果能估计度数,请 reserve 每个 vector,避免多次 reallocation。
  2. 使用连续内存 :如果追求最高性能,优先使用 CSR 或 flat_vector(自实现一维数组 + offset)而不是 vector<vector<T>>
  3. 内存对齐与类型 :顶点索引用 uint32_t 优于 uint64_t(节省空间),除非 n>4e9。
  4. 定期压缩/整理:对动态图,周期性合并/compact 邻接列表以减少碎片。
  5. 访问模式友好:尽量按顶点顺序访问以提高 cache hit(对 BFS/DFS 有益)。
  6. 使用自定义 allocator:避免小块频繁分配的开销(你之前感兴趣的 static memory pool 很适合这里)。

在 SLAM / 图优化 / 路径规划 中的具体应用建议

  • SLAM 因子图:因是稀疏且动态(随着关键帧增长),邻接表或 CSR(周期性 rebuild)为佳。每个邻接项存 factor id /信息矩阵索引 -> 在构造线性化矩阵时可直接映射到稀疏 Hessian。
  • 回环候选管理 :把回环置信度、检测时间、关键点数存到边属性,快速筛选用 adj[u] 遍历并用阈值过滤。
  • A*:邻接表加权图 + 优先队列实现,遍历邻居为 O(k) 最佳。
  • 增量优化:支持快速插入/删除边(swap-erase),并在后端维护稀疏矩阵增量更新。

推荐的工程级模板类

这是一个工程级的邻接表类草案(支持带权、reserve、swap-erase、CSR 导出):

cpp 复制代码
template<typename EdgeAttr = int>
class AdjacencyList {
public:
  using Edge = std::pair<int, EdgeAttr>;
  AdjacencyList(size_t n=0) { resize(n); }

  void resize(size_t n) {
    adj.resize(n);
  }
  size_t size() const { return adj.size(); }

  void reserve_node(size_t u, size_t deg) {
    adj[u].reserve(deg);
  }

  void add_edge(int u, int v, const EdgeAttr& attr = EdgeAttr()) {
    adj[u].emplace_back(v, attr);
  }
  // undirected convenience
  void add_undirected(int u,int v,const EdgeAttr& attr=EdgeAttr()){
    add_edge(u,v,attr); add_edge(v,u,attr);
  }

  // unordered remove (O(1))
  bool remove_edge_unordered(int u, int v) {
    auto &list = adj[u];
    for(size_t i=0;i<list.size();++i) {
      if(list[i].first==v) {
        list[i] = list.back();
        list.pop_back();
        return true;
      }
    }
    return false;
  }

  // export CSR
  void to_csr(std::vector<int>& offset, std::vector<int>& to) const {
    int n = (int)adj.size();
    offset.assign(n+1,0);
    for(int i=0;i<n;++i) offset[i+1] = offset[i] + (int)adj[i].size();
    to.resize(offset.back());
    std::vector<int> cur = offset;
    for(int i=0;i<n;++i){
      for(auto &e: adj[i]) to[cur[i]++] = e.first;
    }
  }

  const std::vector<Edge>& neighbors(int u) const { return adj[u]; }

private:
  std::vector<std::vector<Edge>> adj;
};

4. 邻接矩阵 vs 邻接表对比总结

项目 邻接矩阵 邻接表
内存 O(n²) O(n + m)
图类型 稠密图 稀疏图
查边 ✔ O(1) ✘ O(k)
遍历邻居 O(n) O(k)
增删节点 ❌ 难 ✔ 简单
适用算法 Floyd, DP BFS/DFS, Dijkstra, 图优化

5. 在 SLAM 与图优化中的应用

邻接表 ------ 因子图最常用的数据结构

GTSAM、Ceres、SLAM 后端优化中:

  • 图是稀疏的
  • 每个节点仅连接少量因子

使用邻接表表示 Factor Graph

示例(gtsam):

复制代码
x0 → (imu, odom)
x1 → (imu, loop)
x2 → (odom)
...

每个变量只连接少量因子,所以邻接表最适合。


邻接矩阵 ------ 回环检测与特征匹配中常见

例如:

  • BoW 词典相似度矩阵(ORB-SLAM)
  • GNSS / WiFi RTI 指纹图
  • GPU 加速 pairwise 关系计算

6. 什么时候用哪一种

场景 推荐结构
SLAM 图优化(Factor Graph) 邻接表
稠密图(社交网络、全连接图) 邻接矩阵
BFS / DFS 邻接表
Floyd 全源最短路 邻接矩阵
Dijkstra 稠密图 邻接矩阵
Dijkstra 稀疏图 邻接表 + 堆
GPU 并行计算 邻接矩阵
路径规划(地图网格) 邻接表(隐式图)

相关推荐
江畔柳前堤15 小时前
大语言模型分布式训练:从并行策略到万卡工程的系统梳理
人工智能·分布式·深度学习·算法·目标检测·机器学习·语言模型
Forever Nore16 小时前
LeetCode 4 寻找两个正序数组的中位数 - 二分
算法·leetcode
罗西的思考18 小时前
【OpenClaw具身硬件】MiniClaw 阅读笔记---(1)基础
人工智能·算法·机器学习
蛋先生DX19 小时前
大模型参数存储格式揭秘:BF不是男朋友
深度学习·算法·llm
爱跳舞的烤冷面19 小时前
自学嵌入式第22天(数据结构——哈希)
数据结构·算法·哈希算法
猎嘤一号19 小时前
博弈论(Game Theory)的理论、算法与工程
人工智能·算法·安全·博弈论
月光船幽幽20 小时前
影子模式下保护 logits 不被修改
人工智能·python·算法
马拉AI20 小时前
腾讯开源 Agent 记忆系统,AI“换对话就忘”的问题有了新解法(附安装使用教程)
人工智能·算法·开源·科研
地平线开发者21 小时前
征程6工具链模型X86推理方式说明
算法
地平线开发者21 小时前
【征程6】校准量化中HistogramObserver解析
算法