假设你是新上任的城市交通规划师,手里有一座正在快速扩张的「数字城」。你的任务很简单:把全城的路口和道路记录下来,让任何人在任何时候都能快速回答「这两个路口通不通」「从 A 出发能去哪儿」「这条路堵了要改道」之类的交通问题。听起来容易,可城市越建越大,路口从几十个涨到上万个,道路纵横交错、有单行道有双向路,你手里的账本很快变成了灾难——怎么记录才能不浪费纸张、又能查得快、改得动? 计算机科学里,这个「记录城市道路」的问题,就是图的存储问题。图(Graph)由「顶点」(路口)和「边」(道路)组成,而如何把这张图装进计算机内存,就是「图的表示方法」。你即将学习四种主流画法:邻接矩阵(大公告板)、邻接表(路口路牌)、十字链表(双向公告板)、邻接多重表(去重路牌)。它们各有脾气,适用于不同场景。学完这一课,你不仅能看懂算法书里的存储结构,更能在工程实践中选对「画地图」的方式——这恰恰是构建知识图谱、社交网络推荐、地图导航等 AI 系统的地基工程。

岗位关联

图的表示方法是图论算法的「仓库管理员」——存储方式选对了,后续的最短路径、社区发现、拓扑排序才能跑得快、省内存。这是每位 AI 工程师理解图数据的必修第一课。

目录
  1. 可视化演示
  2. 图的基本概念
  3. 邻接矩阵
  4. 邻接表
  5. 十字链表
  6. 邻接多重表
  7. 对比分析
  8. 选型策略
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

本节配有交互动效(进入视口后自动加载,约 3KB)。

图的基本概念

交通局的第一天,老局长丢给你一张手绘地图:圆圈代表路口,线段代表道路,箭头代表单行道。他说:「这就是图。路口叫顶点,路叫边。你脑子里先别想什么矩阵链表,先搞清楚你记录的是什么东西。」你盯着地图,意识到两个路口之间有路是「无向边」(双向通行),而一条路只能单向走就是「有向边」——整张图就是由顶点和边构成的「关系档案」。

很快你发现,不同的城市长得完全不一样。老城区路口少,但几乎每个路口之间都有路相通,密密麻麻像一张网,这叫稠密图;新城区路口很多,可大部分路口之间根本没路,只有零星的几条主干道,这叫稀疏图。网格员还告诉你一个规律:每条路会被两端的路口各记录一次,所以所有路口的度数(连接它的道路数量)加起来,恰好是道路总数的两倍。

∑v∈Vdeg⁡(v)=2∣E∣\sum_{v \in V} \deg(v) = 2|E|

1. 求和符号 Σ:把图中所有顶点的度数加起来。

∑v∈V\sum_{v \in V}

2. 度数 deg(v):与顶点 v 相连的边数,相当于路口 v 连接的道路条数。

deg⁡(v)\deg(v)

3. 2|E|:边数乘以 2,因为每条无向边都会在两端各贡献一次度数。

2∣E∣2|E|

为什么这个性质重要?因为它是后面判断存储空间时反复用到的「账本原则」:你记录道路的方式,本质上就是在管理这些「双向计数」。搞懂了顶点、边、有向无向、稠密稀疏这四件事,你才能理解为什么有人用公告板、有人用路牌——不同城市的记录方案,天生就该不一样。

邻接矩阵

你决定先采用最直观的方案:在规划局大厅挂一块巨大的公告板。横轴是全部路口的编号,纵轴也是全部路口的编号,于是板子被分成了 n×n 个格子。格子 (i, j) 里打勾,表示路口 i 和路口 j 有路;空白则没有。这块「路口 × 路口」的公告板,就是邻接矩阵——一个二维数组,matrix[i][j] = 1 代表 i 到 j 有边,0 代表没有。

老局长来视察,一眼就爱上了这块板子。因为任何一个人来问「A 和 B 通不通」,你只需看一眼第 A 行第 B 列的格子,一步到位。在计算机里,这就是数组按下标随机访问,时间复杂度是常数时间 O(1)。可没过多久问题来了:老城区路口少还好说,新城一规划就是上万个路口,那块板子要挂整整一万乘一万的格子,其中绝大多数格子都是空的——整面墙都在「浪费白板」。

空间复杂度=O(n2)\text{空间复杂度} = O(n^2)

1. n 的含义:图中顶点的个数,即路口总数。

nn

2. n² 的由来:矩阵有 n 行 n 列,共 n × n 个格子,每个格子存一个 0/1 标记。

n×nn \times n

3. O 记号:表示存储量随 n 增长的「规模级」。n² 意味着当路口翻倍时,板子的面积要变成原来的 4 倍。

O(n2)O(n^2)

所以邻接矩阵的结论是:查询快如闪电,但空间代价巨大。它只适合「老城区」——顶点不多、边足够稠密、格子填得满的稠密图。一旦顶点数量大而边稀疏,这块公告板就成了城市财政的无底洞,逼着你寻找更聪明的方案。

邻接表

你开始反思:为什么非要挂一块 n×n 的大板子?大部分格子明明空着。新方案是这样:不再集中管理,而是在每个路口立一块小牌子,牌子上只写「从这儿出去能到的路口编号」。牌子是块链板——第一块写着第一个邻居,旁边打个铁环拴住下一块,下一块又拴下下一块,直到写完所有能去的方向。这套「路口各管各的链板」,就是邻接表:一个长度为 n 的数组,数组每个元素是一条链表,链表中存的就是该顶点的全部邻居。

效率账本立刻好看了。全城总共需要多少块牌?路口本身 n 块牌,道路 e 条,每条路在起点处挂一块指向对方的牌,所以总共 n + e 块。对比邻接矩阵的 n²,当城市道路稀疏(e 远小于 n²)时,省下的空间相当可观。代价也有:问「A 和 B 通不通」时,你得翻遍 A 那块牌子上的所有邻居链,运气好排第一,运气差排最后——耗时和 A 的度数成正比。

空间复杂度=O(n+e)\text{空间复杂度} = O(n + e)

1. n 个表头:每个路口一块「起始牌」,组成长度 n 的数组。

nn

2. e 个边节点:每条边在起点链表里生成一个邻居节点,共 e 个。

ee

3. n + e 总量:总存储单元是路口牌数加道路记录数,与 n² 相比在稀疏图中优势巨大。

n+en + e

邻接表完美解决了「新城路网稀疏」的痛点:牌子只在有路的地方立,没路的地方一块牌都不浪费。同时,遍历一个顶点的所有邻居变得极其自然——从头牌开始顺着铁环一路摸过去就行,后续学图搜索(BFS/DFS)时,这正是它备受宠爱的原因。但如果你遇到单行道密布的老城,邻接表还有个遗憾:你只记录了「出得去」,却很难快速回答「哪些路口能进得来」。

十字链表

有向图的世界里,「出」和「入」是两码事。单行道 A→B,意味着 A 的牌子要写 B,但 B 的路口若想让人知道「可以从 A 来」,还得另立一本「入城登记簿」。用邻接表时,你想找某个顶点的所有前驱,就得遍历全城的牌子——太慢了。于是聪明的工程师发明了十字链表:每个路口挂两块牌子,一块「出牌」记录能去的方向(出弧),一块「入牌」记录从哪来(入弧),中间用一道横梁钉在一起,两块牌子既各自成链,又通过路口节点互相索引。

更重要的是,这种结构对「动态改路」格外友好。施工队封一条单行道时,只要顺着该弧节点的出链和入链把它摘下来,两边的链表一接就完事,不需要像邻接矩阵那样改两个格子、也不用像邻接表那样翻半天。十字链表把「每条弧」做成一个独立登记对象,它身上同时携带弧头、弧尾、下一条出弧指针、下一条入弧指针——一张「弧的身份证」把出和入全记齐了。

顶点结构:v={data, firstin, firstout}\text{顶点结构:} v = \{ data,\ firstin,\ firstout \}

1. data 数据域:存顶点编号,相当于路口名。

datadata

2. firstin 入链指针:指向第一条「进入该顶点」的弧节点,即入弧链表头。

firstinfirstin

3. firstout 出链指针:指向第一条「从该顶点出发」的弧节点,即出弧链表头。

firstoutfirstout

十字链表在空间上与邻接表同级(O(n+e)),却额外白送了「入边查找」和「高效的弧删除」。它就像给每个单行道路口配了「进口台账 + 出口台账」双本账,道路改向、封路都能快速翻到对应登记页。工程上,编译器分析代码依赖、搜索引擎做网页链接分析时,有向图的双向检索需求强烈,十字链表常常是幕后英雄。

邻接多重表

无向图也有自己的麻烦。你发现在邻接表里记录双向路时,一条路 A—B 被写了两次:A 的链上有 B,B 的链上也有 A。这倒不算浪费,可一旦要删除这条道路,你得同时去 A 和 B 两块牌子上找到对应的链节点,各摘一次——有点手忙脚乱。邻接多重表就是专门来治这个病的:每条无向边只做一个「登记牌」,牌子上写两个端点,并且在这个牌子身上挂两个指针圈:一个圈把「以 A 为一端的所有边」穿起来,另一个圈把「以 B 为一端的所有边」穿起来。

这样一来,A 和 B 的路牌上都不再各自复制一份边,大家共享同一个边对象,只是通过不同的指针「串门」。想删除 A—B 这条路?找到那张登记牌,同时解开两个指针圈,一条边就干净利落地消失了,绝不需要两头找、两头改。空间上它仍然是 O(n + e),但因为边节点只存一次,常数系数比普通邻接表更小,像一个「去重版」的邻接表。

边节点:e={mark, v1, v2, link1, link2}\text{边节点:} e = \{ mark,\ v_1,\ v_2,\ link_1,\ link_2 \}

1. mark 标记位:记录这条边是否被访问过,做遍历算法时有用。

markmark

2. v₁ 与 v₂:无向边的两个端点路口。

v1, v2v_1,\ v_2

3. link₁ 与 link₂:分别指向「依附于 v₁ 的下一条边」和「依附于 v₂ 的下一条边」,把同一条边的两条链串起来。

link1, link2link_1,\ link_2

邻接多重表最迷人的地方在于「一物一牌」:它彻底解决了无向图中同一条边被重复登记的问题。当你的系统需要频繁增删边——比如社交网络的好友关系持续变化、路网维修调度不断切换——这种结构让每次操作都干净利落。它和十字链表互为镜像:一个管有向图的双向弧,一个管无向图的去重边。

对比分析

四张地图摊开在会议室桌上,老局长请你做个横向测评。先看空间:邻接矩阵无脑占用 n²,剩下三者都是 n + e 级别。再看查询:判断任两点是否相邻,矩阵一步到位 O(1),而链表结构都得顺着链找,平均 O(度数)。可若问「遍历某个顶点的所有邻居」,链表结构直接顺着链往下读,矩阵却要扫一整行 n 个格子,反而慢。删除边的场景里,十字链表和邻接多重表因为有边对象和双链,删除效率远高于普通邻接表。

你把这些维度画成一张对比表,发现没有绝对王者,只有「谁更合身」。邻接矩阵像公告板,适合频繁查「通不通」、且图足够稠密的场景;邻接表像路口路牌,适合稀疏图、遍历邻居多的场景;十字链表是单行道城市的「出入双台账」;邻接多重表是双向路城市的「边登记处」。选择的关键词永远是:你的图多密?你的操作偏查询还是偏遍历?边会不会频繁增删?

| 表示方法 | 空间复杂度 | 查询两点相邻 | 遍历某点邻居 | 删除边 |

|---|---|---|---|---|

| 邻接矩阵 | O(n²) | O(1) | 较差(扫整行) | 一般 |

| 邻接表 | O(n+e) | 较慢(找链) | 快(直接读链) | 较慢(两头找) |

| 十字链表 | O(n+e) | 较慢 | 快 | 有向图高效 |

| 邻接多重表 | O(n+e) | 较慢 | 快 | 无向图高效 |

在 AI 工程里,这个「无绝对王者」的结论尤其重要:知识图谱的节点动辄上亿,选错表示方法,一次遍历查询可能从毫秒级退化到分钟级。理解每个方案的强项弱项,是后续所有图算法(PageRank、社区发现、GNN)的性能地基。

选型策略

最后一个问题:真到了工地上,该怎么拍板?你总结出一套口诀。第一步看密度:如果边数 e 接近 n²,说明图密得像渔网,直接上邻接矩阵——格子填得满,浪费少,查询还最快;如果 e 远小于 n²,邻接表立省千倍空间。第二步看操作:你的算法是反复问「A 连不连 B」,还是反复问「A 的所有邻居是谁」?前者矩阵胜,后者链表胜。第三步看动态性:边会频繁插入删除吗?会就考虑十字链表(有向)或邻接多重表(无向),它们把删除边的代价压到最低。

这套策略在真实世界中每天都在上演。大型图数据库 Neo4j 底层用类似邻接表的「免索引邻接」结构,就是为了让「查邻居」快如闪电;而在稠密的小规模子图上,工程师反而会临时构造邻接矩阵,用矩阵乘法并行加速。图神经网络(GNN)里,稀疏邻接表配合专用稀疏矩阵运算库,才能在千万节点上训练——这就是选型策略的力量:没有最好的表示,只有最合适的搭配。

当你日后面对一道算法题、一次系统设计面试,或一个真实的大规模图计算任务时,请回想今天在数字城市里学到的这节课:先摸清图的密度,再想清楚核心操作,最后考虑动态性。城市地图的四种画法,你已经全部收入囊中。

AI 应用场景

场景一:社交网络的好友关系存储

阶段:数据存储与特征预处理

解决的问题:社交平台有数亿用户、数十亿好友关系。传统的「用户-好友」两张表在回答「我的好友里谁也是你的好友」时,需要多次数据库 JOIN,性能极差。采用邻接表存储每个用户的好友链表,只需一次「链表求交集」即可完成共同好友计算。

具体案例:一个推荐系统要给用户推荐「可能认识的人」。它读取目标用户的邻接表(直接好友),再依次读取每个好友的邻接表,统计出现频次最高的二度好友——整个过程全部是链表遍历,不需要建全图矩阵,内存占用和计算速度都得到质的提升。

场景二:地图导航中的路网建模

阶段:路径搜索与路由规划

解决的问题:导航软件需要存储一个城市的路网(交叉点为顶点、路段为边),并快速执行 Dijkstra 最短路径算法。稀疏的真实路网如果用邻接矩阵存储,内存直接爆炸;用邻接表存储每条路的相邻关系,配合优先队列,每次出队只需遍历当前路口的少量邻居。

具体案例:高德或 Google Maps 在规划「从家到公司」的路线时,后台的图引擎维护着全城的邻接表路网,实时路况相当于动态更新边权。因为邻接表对「读邻居」极其友好,算法每次只展开路口的少数相邻道路,确保毫秒级响应。

场景三:编译器依赖关系的拓扑排序

阶段:图分析与任务调度

解决的问题:代码模块之间存在单向依赖(A 依赖 B),构成有向图。构建系统需要做拓扑排序决定编译顺序。如果用普通邻接表,找「谁依赖我」很慢;用十字链表同时维护出弧和入弧,既能快速展开依赖项,又能快速找到被依赖者,配合入度计算实现高效的调度。

具体案例:大型项目的构建工具(如 Bazel、Gradle)解析数百个模块的依赖图时,底层用类似十字链表的结构存储有向依赖关系。当某个模块更新,系统只需沿着该节点的出弧和入弧找出受影响的模块集合,即可实现增量编译。

Python 演示

python51 行
class GraphMatrix:    """邻接矩阵:n x n 的二维数组,arr[i][j] 为 1 表示 i 到 j 有边。"""     def __init__(self, n):        self.n = n        self.mat = [[0] * n for _ in range(n)]     def add_edge(self, u, v):        self.mat[u][v] = 1        self.mat[v][u] = 1     def space(self):        return self.n * self.n     def has_edge(self, u, v):        return self.mat[u][v] == 1  class GraphList:    """邻接表:n 个列表,adj[u] 里存 u 的所有邻居。"""     def __init__(self, n):        self.n = n        self.adj = [[] for _ in range(n)]     def add_edge(self, u, v):        self.adj[u].append(v)        self.adj[v].append(u)     def space(self):        return self.n + sum(len(x) for x in self.adj)     def has_edge(self, u, v):        return v in self.adj[u]  if __name__ == "__main__":    gm = GraphMatrix(6)    gl = GraphList(6)    edges = [(0, 1), (1, 2), (2, 5), (0, 3), (3, 4), (4, 5)]     for u, v in edges:        gm.add_edge(u, v)        gl.add_edge(u, v)     print("顶点数 n =", 6)    print("边数 e =", len(edges))    print("邻接矩阵占用单元:", gm.space())    print("邻接表占用单元:", gl.space())    print("查询边 (1, 4):", gm.has_edge(1, 4), gl.has_edge(1, 4))    print("查询边 (0, 2):", gm.has_edge(0, 2), gl.has_edge(0, 2))
目的:用同一张无向图分别构造邻接矩阵和邻接表,量化对比两者存储单元的差异,并演示查询行为。
  1. self.mat = [[0] * n for _ in range(n)]生成 n 行 n 列的全零矩阵,模拟「公告板」的空白格子。
  2. self.mat[u][v] = 1在公告板的 (u, v) 位置打勾,记录一条边。
  3. def space(self):统计占用单元数,矩阵固定为 n²,能直观看到顶点增加时的爆炸式增长。
  4. self.adj = [[] for _ in range(n)]创建 n 个空列表,每个列表就是一块「路口路牌」。
  5. self.adj[u].append(v)在 u 的路牌上钉一块写有 v 的新链板。
  6. self.n + sum(len(x) for x in self.adj)邻接表空间算的是「n 块路牌 + 所有链板总数」,对应 O(n+e)。
  7. return v in self.adj[u]查询时顺着 u 的链板逐个找 v,体现了链式结构查询的耗时特性。
  8. gm.has_edge(1, 4)对矩阵来说一次下标访问就出结果,与链表的线性查找形成鲜明对比。

常见误区

误区很多人学完「矩阵浪费空间」后,认为邻接表在任何情况下都优于邻接矩阵,选型时无脑用邻接表。

正解当图很稠密(边数接近 n²)时,邻接矩阵的格子几乎全被填满,空间浪费几乎为零,却换来了 O(1) 的查边速度。反观邻接表,每个边节点还有指针开销,反而更费内存。就像公告板格子虽多,但每格都有用;路牌链表还得额外采购铁环和牌子,未必更省钱。选型要看密度,不能一刀切。

为何常见:教材里经常强调矩阵的空间复杂度 O(n²) 很高,初学者记住了「坏」却没记住「有条件地坏」,忽略了稠密图这个前提。

误区有同学以为邻接表是给无向图用的,而邻接矩阵存不了有向图,遇到有向图就不知所措。

正解邻接矩阵完全支持有向图,只是矩阵不再关于对角线对称。arr[i][j] 表示「i → j 有边」时,arr[j][i] 可能为 0。就像路口的公告板,单行道只在「去的方向」那格打勾,回来方向的格子留空即可。有向矩阵同样有 O(1) 查边优势,只是空间问题依旧存在。

为何常见:很多教材用无向图举例矩阵,用有向图举例邻接表,容易让学生误以为两者「各管一种图」,忽略了它们都能表示两类图。

误区两种结构名字都带「链表」,还都支持高效删边,初学者常把它们混为一谈。

正解十字链表专为有向图设计,每个弧节点同时维护出弧和入弧两条链;邻接多重表专为无向图设计,每条边只存一次,两个顶点各引一条链。一个管「单行道双向台账」,一个管「双向路去重登记」,应用场景完全不同。类比:一个有进口台账和出口台账,一个是共享登记牌。

为何常见:二者的节点结构看起来都有「两个指针」,加上教材常把它们放在同一个小节里对比,名称又都很「高级」,学生容易只记结论不记适用条件。

误区有同学认为「邻接表空间 O(n+e),所以只要边少,顶点再多也无所谓」。

正解邻接表仍然需要 n 个表头指针存放每个顶点的链表入口。当顶点数 n 本身巨大(比如十亿用户)时,光表头数组就占很大内存。工程上常对邻接表做压缩(如 CSR 格式)来降低这部分开销。就像即便每条路都很稀疏,你仍得给每个路口钉一块牌子,路口太多,光买牌子也是不小开支。

为何常见:大家在比较 n² 和 n+e 时,注意力都放在 e 上,忽略了 n 这个「守恒项」在大规模场景下的绝对数值。

面试题

Q1. 邻接矩阵用二维数组存储图,它的空间复杂度是?

单选题 · easy

  • A.O(n)
  • B.O(n²)← 正确答案
  • C.O(n+e)
  • D.O(e)

答案:B。邻接矩阵无论边多边少,都需要 n 行 n 列共 n² 个格子,所以空间复杂度是 O(n²)。O(n+e) 是邻接表等链式结构的复杂度,O(n)、O(e) 都不完整。

Q2. 对于一个边数远小于 n² 的稀疏图,最推荐的存储方式是?

单选题 · easy

  • A.邻接矩阵
  • B.邻接表← 正确答案
  • C.十字链表
  • D.邻接多重表

答案:B。稀疏图用邻接表,因为存储量 O(n+e) 远小于矩阵的 O(n²)。十字链表和邻接多重表虽然也省空间,但结构更复杂,若无特殊操作需求,邻接表是优先选择。

Q3. 无向图用邻接矩阵存储时,矩阵具有什么特征?

单选题 · mid

  • A.主对角线全为 1
  • B.关于主对角线对称← 正确答案
  • C.每一行只有一个 1
  • D.是一个上三角矩阵

答案:B。无向边 i—j 同时让 arr[i][j] 和 arr[j][i] 为 1,所以矩阵关于主对角线对称。若是有向图则不保证对称;主对角线通常为 0(无自环时)。

Q4. 十字链表是针对哪类图设计的存储结构?

单选题 · mid

  • A.无向图
  • B.有向图← 正确答案
  • C.带权图
  • D.二分图

答案:B。十字链表为每个顶点分别维护「出弧链表」和「入弧链表」,专门解决有向图中查找前驱慢的问题。无向图对应的去重结构是邻接多重表。

Q5. 邻接多重表的核心设计思想是?

单选题 · easy

  • A.用矩阵存边
  • B.每条边只存储一次← 正确答案
  • C.用哈希表存边
  • D.每个顶点复制所有边

答案:B。邻接多重表把每条无向边做成一个登记节点,两个端点通过不同指针共享该节点,避免了邻接表里一条边存两次的问题。

Q6. 判断两个顶点是否相邻,时间复杂度为 O(1) 的结构是?

单选题 · mid

  • A.邻接表
  • B.十字链表
  • C.邻接矩阵← 正确答案
  • D.邻接多重表

答案:C。邻接矩阵按下标直接访问 arr[i][j] 即可,一步到位。链表类结构必须沿链表逐个查找,时间与顶点度数相关。

Q7. 一个无向图有 n 个顶点 e 条边,用邻接表存储时,总共所需的存储单元数量级是?

单选题 · hard

  • A.O(n)
  • B.O(n²)
  • C.O(n+e)← 正确答案
  • D.O(n×e)

答案:C。邻接表需要 n 个表头(每个顶点一个),每条边在两端各形成一个邻居节点,共 2e 个节点,所以总量 O(n+e),常数系数 2 不影响数量级。

Q8. 下列关于邻接表的说法正确的有?

多选题 · mid

  • A.适合存储稀疏图← 正确答案
  • B.遍历某顶点的所有邻居很高效← 正确答案
  • C.判断两点是否相邻比矩阵更快
  • D.存储空间为 O(n+e)← 正确答案

答案:ABD。邻接表只存有边关系的邻居,空间 O(n+e),适合稀疏图;遍历邻居直接读链表,效率高。但判断任意两点相邻需沿链查找,比矩阵的 O(1) 慢,所以 C 错。

Q9. 在需要频繁删除边的无向图场景中,哪些结构比普通邻接表更合适?

多选题 · mid

  • A.邻接矩阵
  • B.邻接多重表← 正确答案
  • C.十字链表← 正确答案
  • D.邻接表

答案:BC。邻接多重表边只存一次,删边时解开两个指针即可;十字链表虽为有向图设计,但对有向边的删除同样高效。邻接矩阵删边要改两处,普通邻接表删边要两头找节点,效率较低。

Q10. 关于十字链表的顶点结构,包含哪些部分?

多选题 · hard

  • A.firstin 指针← 正确答案
  • B.firstout 指针← 正确答案
  • C.data 数据域← 正确答案
  • D.weight 权值域

答案:ABC。十字链表顶点节点含 data(顶点数据)、firstin(入弧链表头)、firstout(出弧链表头)。权值存在弧节点中而非顶点节点,且标准的十字链表顶点不直接存权重,故 D 不选。