ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

邻接表、邻接多重表与十字链表:图存储结构的深度对比与实战选型

2026/8/2 9:49:42 拓冰建站 浏览量
邻接表、邻接多重表与十字链表:图存储结构的深度对比与实战选型 1. 从“图”说起为什么我们需要不同的存储结构在计算机的世界里图Graph是一种极其强大的数据结构它擅长描述实体顶点以及它们之间错综复杂的关系边。从社交网络的好友关系到地图上的道路连接再到电路板上的元器件布线图的影子无处不在。当我们把现实问题抽象成图模型后接下来的核心挑战就是如何在计算机里高效地“装下”这张图很多初学者甚至一些有经验的开发者在接触图算法时往往直接上手邻接矩阵。这很自然因为邻接矩阵直观得像一张二维表格行和列都代表顶点矩阵中的值表示边是否存在或边的权重。对于稠密图边数接近顶点数的平方邻接矩阵的空间利用率高查询任意两顶点间是否有边是O(1)的常数时间非常快。但现实世界中的图往往是“稀疏”的。想象一下微信的好友关系网你有几百个好友但你的好友之间可能大部分互不认识。这意味着对于拥有数亿用户的微信如果用邻接矩阵存储将是一个数亿行、数亿列的巨型矩阵其中绝大部分单元格都是0表示“非好友”。这不仅是存储空间的巨大浪费遍历你所有好友的操作也需要扫描矩阵中对应你的一整行数亿个元素效率极低。这就是邻接表Adjacency List诞生的动机。它改变了思路不再为所有可能的关系预留空间而是只为实际存在的关系边分配存储。具体来说它为每个顶点维护一个链表或动态数组链表中只存储与该顶点直接相连的邻居顶点。这样一来存储空间从O(V²)降到了O(VE)V是顶点数E是边数。遍历一个顶点的所有邻居也变得非常高效直接遍历其链表即可。然而邻接表并非终点。当我们对图的操作不仅仅局限于“从一个顶点出发找它的邻居”而是涉及到“对某条边本身进行操作”时邻接表的局限性就暴露了。比如在无向图中删除一条边我们需要在两个顶点的链表中分别找到并删除对应节点这个过程是低效的。又比如在某些图算法或图形编辑软件中我们需要快速访问或修改一条边的属性如权重、颜色。为了解决这些更复杂的需求工程师们设计出了两种在邻接表基础上进化而来的结构邻接多重表Adjacency Multilist和十字链表Orthogonal List。它们可以看作是邻接表的“升级版”核心思想是将“边”作为一个独立的、一等公民的对象来存储和管理从而支持对边的高效操作。接下来我们就深入这三种结构的内部看看它们是如何工作的以及各自在什么场景下能大放异彩。2. 邻接表稀疏图存储的基石与实现细节邻接表是理解图存储结构的起点也是实际应用中最常见的选择。它的设计哲学是“按需分配”完美契合了稀疏图的特性。2.1 核心数据结构剖析邻接表的核心由两部分组成顶点表Vertex Array一个一维数组每个元素对应图中的一个顶点。这个元素至少需要包含两部分信息顶点的数据或标识符以及一个指向该顶点第一条邻接边的指针通常是链表的头指针。边链表Edge Lists每个顶点都拥有一个链表链表中的每个节点代表从该顶点出发的一条边。对于有向图这个链表叫“出边表”对于无向图每条边会在两个顶点的链表中各出现一次。我们以一个简单的无向图为例它有顶点A, B, C, D边为(A-B), (A-C), (B-C), (C-D)。其邻接表结构如下顶点数组索引 | 顶点数据 | 边链表头指针 ----------------------------------- 0 | A | - [B] - [C] - NULL 1 | B | - [A] - [C] - NULL 2 | C | - [A] - [B] - [D] - NULL 3 | D | - [C] - NULL这里的边链表节点通常至少包含两个字段adjvex邻接顶点在顶点数组中的索引和next指向下一个邻接节点的指针。如果边有权重还会增加一个weight字段。2.2 代码实现与关键操作用C语言来描述一个典型的邻接表结构定义如下// 边表节点 typedef struct EdgeNode { int adjvex; // 邻接点下标 int weight; // 权值无权图可省略 struct EdgeNode *next; // 指向下一个邻接点 } EdgeNode; // 顶点表节点 typedef struct VertexNode { char data; // 顶点信息 EdgeNode *firstedge; // 边表头指针 } VertexNode, AdjList[MAXVEX]; // 图结构 typedef struct { AdjList adjList; // 顶点数组 int numVertexes, numEdges; // 顶点数和边数 } GraphAdjList;创建图初始化时我们需要读入顶点和边。对于每条边(u, v)我们会在顶点u的边表头部插入一个指向v的新节点头插法O(1)时间复杂度。对于无向图还需要对称地在顶点v的边表中插入指向u的节点。遍历操作深度优先搜索DFS和广度优先搜索BFS是图算法的基石。在邻接表上实现它们非常直观。DFS从某个顶点出发访问它然后递归地访问它的每一个未被访问过的邻接点。邻接表使得我们能快速获取所有邻接点。BFS需要借助队列。从起始顶点开始将其入队并访问。然后当队列不为空时出队一个顶点并将其所有未被访问的邻接点入队并访问。邻接表同样高效支持此操作。查找与删除查找边(u, v)需要遍历顶点u的边链表查找adjvex等于v的节点。平均时间复杂度为O(degree(u))即顶点u的度。删除边(u, v)对于无向图这更麻烦。我们需要在u的链表中找到指向v的节点并删除同时还要在v的链表中找到指向u的节点并删除。这需要两次链表遍历和删除操作效率不高这也是邻接表的一个主要缺点。2.3 优势、劣势与适用场景优势空间效率高对于稀疏图空间复杂度为O(VE)远优于邻接矩阵的O(V²)。遍历高效查找一个顶点的所有邻接点即求其度非常快只需遍历其链表。动态增删顶点相对容易添加新顶点只需扩展顶点数组或使用动态数组添加新边只需在对应链表插入节点。劣势判断任意两顶点间是否有边效率低必须遍历其中一个顶点的链表最坏情况O(V)。删除边操作繁琐尤其在无向图中需操作两个链表。边信息冗余对于无向图同一条边的信息存储了两份。难以对“边”进行集中操作边被分散在各个顶点的链表中如果想对所有边执行某个操作如统计边数、按权重排序所有边需要遍历所有链表不够直接。实操心得在实际工程中如果图非常稀疏且以顶点为中心的遍历操作如BFS/DFS为主邻接表是首选。在实现时我经常用vectorlistintC或ListListIntegerJava来快速原型它们内部已经处理了动态内存。对于性能要求极高的场景可以考虑用vectorvectorint邻接动态数组代替链表因为连续内存访问对CPU缓存更友好遍历更快虽然插入删除中间元素会变慢。3. 邻接多重表无向图边操作的优化方案邻接表在删除无向图的边时表现不佳因为它存储了边的两个副本。邻接多重表的巧妙之处在于它让一条边只对应一个物理存储节点但这个节点同时存在于两个相关的顶点链表中。这就像一条边被“共享”了。3.1 数据结构设计精妙之处邻接多重表的边节点结构是关键它包含了指向前后节点的指针但这些指针是分属不同链表的。// 边表节点结构 typedef struct EBox { int ivex, jvex; // 该边依附的两个顶点在顶点数组中的下标 struct EBox *ilink, *jlink; // 分别指向依附于顶点ivex和jvex的下一条边 // int weight; // 权值可选 // bool mark; // 访问标记可选 } EBox; // 顶点表节点结构 typedef struct VexBox { char data; // 顶点信息 EBox *firstedge; // 指向第一条依附于该顶点的边 } VexBox;理解ilink和jlink假设有一条边连接了顶点i和顶点j对应ivexi,jvexj。ilink指向的是下一条依附于顶点i的边。jlink指向的是下一条依附于顶点j的边。这样所有依附于顶点i的边通过各自的ilink如果该边的ivexi或jlink如果该边的jvexi指针串成了一条链表。顶点i的firstedge就指向这条链表的头节点。3.2 构建与遍历过程演示假设我们有一个无向图顶点0,1,2,3边为(0,1), (0,2), (1,2), (2,3)。构建过程如下插入边(0,1)创建边节点E1ivex0,jvex1。将E1链接到顶点0和顶点1的边链表中通常是头插法。顶点0的firstedge- E1。E1的ilink指向NULL因为它是顶点0链表的第一条边。顶点1的firstedge- E1。E1的jlink指向NULL。插入边(0,2)创建E2ivex0,jvex2。链接到顶点0E2的ilink指向当前顶点0的firstedge即E1然后更新顶点0的firstedge为E2。链接到顶点2E2的jlink指向当前顶点2的firstedgeNULL更新顶点2的firstedge为E2。同理插入其他边。最终从顶点0出发通过firstedge找到E2(0,2)通过E2的ilink找到E1(0,1)再通过E1的ilink找到NULL就遍历完了所有与顶点0相连的边。删除边操作的优越性在此体现要删除边(0,1)即E1我们只需要修改顶点0和顶点1的边链表中指向E1的指针。由于边是共享的我们只需找到并修改指针即可无需像邻接表那样删除两个节点。查找前驱指针虽然仍需遍历但物理上只操作一个节点。3.3 核心优势与适用边界核心优势边信息唯一存储解决了无向图中边信息冗余的问题。边删除操作更高效虽然仍需遍历查找前驱但只需处理一个边节点逻辑更清晰在某些场景下性能优于邻接表。便于对边进行标记或操作因为每条边是独立节点可以方便地添加visited标记、权重、或其他属性并基于边进行算法设计。劣势与边界结构更复杂指针增多代码实现和调试难度高于邻接表。空间开销未必更小虽然边不重复存储但每个边节点需要存储两个顶点索引和两个链接指针而邻接表的边节点只需一个顶点索引和一个指针。在边数非常多时需要仔细权衡。主要针对无向图其设计对称性天然适合无向图。对于有向图虽然可以改造例如用ilink表示出边jlink表示入边但不如十字链表直观。踩坑实录我第一次实现邻接多重表时在删除边的逻辑上栽了跟头。问题在于当要删除的边节点恰好是某个顶点边链表的头节点时需要特殊处理更新该顶点的firstedge指针。我最初只考虑了修改前驱节点的ilink或jlink漏掉了更新firstedge的情况导致链表断裂。关键检查点在修改指针前一定要判断if (prev NULL)即被删除节点是否是头节点如果是则更新对应顶点的firstedge否则更新前驱节点的对应链接指针。4. 十字链表有向图操作的终极利器如果说邻接多重表是为无向图量身定做那么十字链表就是为有向图精心设计的存储结构。它同样只存储边的一个副本但能同时高效地支持“从顶点出发找其出边”和“从顶点出发找其入边”这两种关键操作。4.1 十字链表的双链设计哲学十字链表的核心思想是为每条有向边建立一个节点这个节点同时加入两个链表出边链表和入边链表。出边链表所有以同一个顶点为起点的边通过指针链接起来。入边链表所有以同一个顶点为终点的边通过另一个指针链接起来。这样每个边节点就有四个指针域通常结构如下// 弧边节点结构 typedef struct ArcBox { int tailvex, headvex; // 弧尾起点和弧头终点在顶点数组中的下标 struct ArcBox *hlink, *tlink; // 分别指向弧头相同和弧尾相同的下一条弧 // int weight; // 权值可选 // InfoType *info; // 其他信息可选 } ArcBox; // 顶点节点结构 typedef struct VexNode { char data; // 顶点信息 ArcBox *firstin, *firstout; // 分别指向以该顶点为弧头和弧尾的第一个弧节点 } VexNode;理解指针hlink用于链接弧头相同即终点相同的下一条边。所有终点为顶点v的边通过hlink串成一个链表顶点v的firstin指向这个链表的头。tlink用于链接弧尾相同即起点相同的下一条边。所有起点为顶点u的边通过tlink串成一个链表顶点u的firstout指向这个链表的头。4.2 构建、查询与删除操作全解析以一个简单的有向图为例顶点0,1,2边为0,1, 0,2, 1,2。构建过程插入边0,1创建弧节点A1tailvex0,headvex1。链接出边链顶点0A1的tlink指向当前顶点0的firstoutNULL更新顶点0的firstout为A1。链接入边链顶点1A1的hlink指向当前顶点1的firstinNULL更新顶点1的firstin为A1。插入边0,2创建A2tailvex0,headvex2。链接出边链顶点0A2的tlink指向当前顶点0的firstoutA1更新顶点0的firstout为A2。现在顶点0的出边链是 A2 - A1。链接入边链顶点2A2的hlink指向当前顶点2的firstinNULL更新顶点2的firstin为A2。同理插入边1,2。高效查询求顶点v的所有出边后继从firstout开始沿tlink指针遍历即可。时间复杂度O(out-degree(v))。求顶点v的所有入边前驱从firstin开始沿hlink指针遍历即可。时间复杂度O(in-degree(v))。判断边u, v是否存在需要遍历顶点u的出边链或顶点v的入边链。最坏情况O(max(out-degree(u), in-degree(v)))。在稀疏图中这通常可以接受。删除边操作要删除边u, v我们需要在顶点u的出边链表中找到该边节点及其前驱修改前驱的tlink或直接修改firstout。在顶点v的入边链表中找到该边节点及其前驱修改前驱的hlink或直接修改firstin。最后释放该边节点内存。 虽然也需要两次链表操作但操作的是同一个物理节点逻辑清晰。4.3 为何是处理有向图的理想选择十字链表完美解决了有向图邻接表的几个痛点高效获取入度/出度信息邻接表只能方便地获取出边出度要获取入边入度必须遍历整个边集效率是O(E)。而十字链表通过firstin链表获取入边的时间复杂度是O(in-degree(v))对于入度小的顶点优势巨大。边信息唯一同邻接多重表。特别适合需要频繁同时访问入边和出边的算法例如在计算有向图的强连通分量Kosaraju或Tarjan算法、关键路径分析等问题中需要同时知道顶点的前驱和后继十字链表能提供常数时间获取链表头然后线性遍历的支持非常高效。个人经验与选型建议在实际项目中除非有非常明确的、需要频繁查询顶点入边的需求否则邻接表因其实现简单、足够高效仍然是大多数情况下的默认选择。十字链表和邻接多重表属于“特化”优化在特定的问题领域如编译器中的控制流图分析、某些图数据库的内部表示才会大显身手。我的建议是先熟练掌握邻接表当你在实现某些算法如拓扑排序的逆向删除、有向图的可达性分析时如果感觉“获取入边”的操作成了瓶颈再来考虑引入十字链表进行优化。不要为了“高级”而使用复杂结构清晰和可维护性永远是第一位的。5. 三种结构的对比与实战选型指南纸上谈兵终觉浅绝知此事要躬行。理解了原理最终要落到“怎么选”和“怎么用”上。下面我们从多个维度对这三种结构进行系统性对比并给出清晰的选型决策路径。5.1 多维对比表格特性维度邻接表邻接多重表十字链表适用图类型有向图、无向图主要无向图主要有向图边存储方式每条边存储2次无向图或1次有向图每条边存储1次每条边存储1次核心优势结构简单实现容易遍历顶点的邻接点最快无向图中边删除、标记等操作更高效能高效同时获取顶点的入边和出边空间开销无向图O(V2E)有向图O(VE)O(VE)但边节点指针更多O(VE)边节点指针最多4个查询边(u,v)O(degree(u)) 或 O(degree(v))O(degree(u)) 或 O(degree(v))O(out-degree(u)) 或 O(in-degree(v))删除边(u,v)无向图需操作2个链表节点有向图操作1个链表节点只需操作1个边节点的链接但需更新两个链表只需操作1个边节点的链接但需更新两个链表获取顶点v所有邻接点遍历v的链表即可O(degree(v))遍历v关联的边链表O(degree(v))出边O(out-degree(v))入边O(in-degree(v))获取顶点v的入边前驱低效需遍历所有边O(E)不适用无向图无此概念高效O(in-degree(v))代码复杂度最低较高最高典型应用场景通用图算法DFS, BFS, Dijkstra, Prim等社交网络路径规划图形编辑软件需高频率增删、选中边某些物理仿真引擎编译器控制流图/数据流图有向图的可达性分析工作流引擎5.2 根据场景决策的流程图面对一个具体问题你可以遵循以下思路来选择开始 | v 判断图的主要类型 | |--- [无向图] --- 是否需要高频执行边的删除、标记或基于边的操作 | | | | |--[是]--- 选择【邻接多重表】 | |--[否]--- 选择【邻接表】默认、最简单 | |--- [有向图] --- 算法是否需要频繁查询顶点的入边前驱 | | |--[是]--- 选择【十字链表】 |--[否]--- 选择【邻接表】默认、最简单 | v 考虑额外约束空间极其紧张代码维护性优先 | |--- 空间优先邻接表通常更紧凑指针少。 |--- 维护性优先邻接表实现简单bug少。 | v 最终选择举例说明场景一实现一个简单的朋友圈好友推荐算法无向图。图很稀疏操作主要是从某个用户出发进行BFS/DFS遍历。几乎不删除边。选型邻接表。简单可靠完全够用。场景二开发一个电路板布线图的交互式编辑器无向图。用户需要频繁地选中、删除、移动导线边。选型邻接多重表。将边作为独立对象管理选中和删除操作更直接高效。场景三为编译器实现中间代码的控制流分析有向图。需要分析每个基本块顶点的前驱块和后继块来构建支配树或进行数据流分析。选型十字链表。可以快速获取基本块的所有入边前驱和出边后继是算法效率的关键。5.3 性能优化的延伸思考选定基本结构后还可以根据具体需求进行微优化链表 vs 动态数组邻接表中每个顶点的邻居列表是用链表还是动态数组如C的vector链表便于中间插入删除但内存不连续遍历慢。动态数组遍历快缓存友好但中间插入删除慢。如果图是静态的或很少修改但需要高频遍历用动态数组性能更好。边的存储内容边节点里除了目标顶点索引还可以存储权重、流量、时间戳、标记位等。在设计数据结构时要预留扩展空间或使用灵活的结构如联合体union或附加信息指针。顶点表索引顶点表用数组索引访问最快。但如果顶点需要频繁增删可以考虑用哈希表将顶点ID映射到内部索引外部使用ID内部使用连续索引兼顾灵活性和性能。在我经历的一个网络拓扑分析项目中最初使用了邻接表。后来发现需要频繁统计每个网络设备的“入向流量”即所有指向它的边的权重和这需要遍历所有边在百万级边数的图上成了瓶颈。我们将数据结构重构为十字链表将“计算入向流量”的操作从O(E)优化到了O(VE)的预处理构建入边链表加上后续O(in-degree(v))的查询整体性能提升了一个数量级。这个案例深刻说明对数据结构的深刻理解和对应用场景的精准分析是写出高效代码的前提。不要害怕在项目中期重构基础数据结构有时这是通往高性能的必经之路。