1. 图(Graph)的基本概念
一个图 ( G = (V, E) ) 包含:
- 节点 / 顶点(Vertex):( V )
- 边(Edge):( E )
分为:
- 无向图
- 有向图
- 带权图(权重通常为距离、概率等)
2. 邻接矩阵(Adjacency Matrix)
2.1 定义
邻接矩阵 (A) 对于有 (n) 个顶点的图是一个 (n\times n) 的二维数组。常见变体:
- 无向无权图 :
(A[i][j] = 1) 表示存在边,(0) 表示无边。对称矩阵:(A[i][j] = A[j][i])。 - 有向无权图 :
(A[i][j] = 1) 表示从 (i) 指向 (j) 的有向边(不一定对称)。 - 带权图 (有向或无向):
(A[i][j] = w_{ij})(例如边长、代价、相似度)。无边处通常用特殊值表示(0、INF、或NaN,取决于语义)。 - 自环:如果允许自环,则对角线 (A[i][i]) 可为 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^2bytes。
例如 (n=10000) 时:8 * 1e8 = 800MB(仅矩阵)。 - 若使用
bool或位图(bitset):可以压缩到约n^2 / 8bytes------仍会很快变大。
工程建议
- (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 可配置;与你内存布局要匹配以免额外拷贝。
附:邻接矩阵的常见优化与变体
- 只存上(三角)或下三角(对称无向图):节省一半空间,但访问需做索引变换。
- 压缩行(CSR / CSC) :虽然这本质是稀疏矩阵格式,若邻接矩阵非常稀疏,可以把邻接矩阵视作稀疏矩阵存储,利用
row_ptr,col_idx,val来节省空间并加快遍历邻居(与邻接表类似但更便于数值运算)。 - 位图 + 权值分离:存在性用位图,权值另存(仅保存实际边)。对查询速度与存储权衡有利。
- 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 顺序与版本一致。
- 若算法频繁增删节点,应避免邻接矩阵。
实用算法示例(邻接矩阵场景优先)
- Floyd--Warshall(全源最短路):直接在邻接矩阵上三重循环 O(n^3)。
- 路径计数(A^k):矩阵幂可以求长度恰为 k 的路径数量,可用快速幂或并行矩阵乘法。
- 谱聚类 / 特征值分解:从邻接矩阵构造度矩阵 (D) 与拉普拉斯 (L = D - A),然后求前 k 个特征向量。
- 快速公共邻居 / 相似度:若用位矩阵,公共邻居数可通过位与 + popcount 迅速计算;适用于 loop closure 提名。
3. 邻接表(Adjacency List)
3.1 定义
邻接表为每个顶点维护一个容器(vector、list、deque、甚至链表索引数组),存储该顶点的所有邻居(以及可选的边属性,如权重、时间戳、信息矩阵等)。
常见表示:
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+1,to长度m(或2m),极佳压缩比;
工程量化示例:
-
(n=10^6, m=5\times10^6)(稠密度低):
vector<vector<int>>可能因每个 vector 分配开销显著;- CSR 存储
offset+to仅需 ~8*(n+1) + 4*mbytes(假设 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);若不在意顺序,可swap与pop_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 表示(静态图或批量构建首选)
构建步骤:
- 统计每个节点度
deg[u] offset[0]=0; offset[i+1]=offset[i]+deg[i]- 填充
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 构建步骤。
并发/性能优化建议
- 预分配(reserve) :如果能估计度数,请
reserve每个vector,避免多次 reallocation。 - 使用连续内存 :如果追求最高性能,优先使用 CSR 或
flat_vector(自实现一维数组 + offset)而不是vector<vector<T>>。 - 内存对齐与类型 :顶点索引用
uint32_t优于uint64_t(节省空间),除非 n>4e9。 - 定期压缩/整理:对动态图,周期性合并/compact 邻接列表以减少碎片。
- 访问模式友好:尽量按顶点顺序访问以提高 cache hit(对 BFS/DFS 有益)。
- 使用自定义 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 并行计算 | 邻接矩阵 |
| 路径规划(地图网格) | 邻接表(隐式图) |