Appearance
邻接表
2026 大纲 五(二)图的存储及基本操作 2. 邻接表(另两种见《邻接矩阵》《十字链表与邻接多重表》)。
为空格子付钱,不如只为边付钱
邻接矩阵的问题在《那一篇》里说过:空间
邻接表换了个思路:只为真实存在的边花空间。每个顶点挂一条链,链上串着它的邻居,有几个邻居就串几个结点。没有边,就没有结点。
于是它长成两级结构:
- 顶点表:长度为
的顺序数组,每一项含顶点数据 data和一个指针firstarc。 - 边表:每个顶点的
firstarc挂出一条单链表,结点含adjvex(邻居的编号)、nextarc,带权图再加一个info存权值。
⚠️ 顶点表必须是数组,不能也换成链表。 换了以后"按编号随机访问某个顶点"就从
先动手看一眼
拨的时候盯两件事:一是无向图里同一条边出现了两次(两个端点的链上各一份);二是换个次序建同一张图,链上结点的排列会变——这一点后面会一路影响到遍历序列。
数结点:无向图是 个,那个 2 不能丢
边结点到底有多少个?分两种情况,而且差一倍:
- 有向图
个。弧 只挂在弧尾 的链上。 - 无向图
个。边 必须在 和 两条链上各存一份。
无向图为什么非得存两份?因为邻接表是"从某个顶点出发去找它的邻居"的结构。只在
由此空间是
- 省掉
(写成 )是错的,边结点实实在在占着空间。 - 省掉
(写成 )也是错的——即使一条边都没有,顶点表仍然占 个单元。这一条在真题里被专门设过陷阱,下面还会再碰到。
出度容易,入度困难
这是邻接表最重要的一处不对称,也是后面两种存储结构存在的全部理由。
有向图求出度:顶点
有向图求入度:麻烦了。指向 adjvex 是不是
c
// 有向图求入度:无法只看第 i 条链,必须扫全表
int inDegree(ALGraph *G, int i) {
int d = 0;
for (int u = 0; u < G->vexnum; u++)
for (ArcNode *p = G->vertices[u].first; p; p = p->next)
if (p->adjvex == i) d++;
return d;
}代价是
🔴 这里的
不能扔。有人会觉得"反正是在数边,写 就行"——但即使某个顶点的链是空的,你也得访问一次它的表头才知道它是空的。当图极稀疏、 时,扫表头的 才是主要开销。 与 是同一个量,真题出现过写成 形式的版本,别因为长得不一样就否掉它。
针对入度这个短板,有两条补救路线:
- 逆邻接表:另建一套链,串"进入
的弧"。求入度降到 ,代价是求出度反过来变难,而且两张表要同步维护。 - 十字链表:让一份弧结点同时挂在弧尾链和弧头链上,两个方向都是
,还省掉了同步。它是逆邻接表的进化版。
建表用头插,代价是次序反了
建表时在链头插结点,
c
typedef struct ArcNode { // 边结点
int adjvex; // 该边指向的顶点编号
// int weight; // 带权图可加权值字段(教材称 info)
struct ArcNode *next;
} ArcNode;
typedef struct VNode { // 顶点结点
char data;
ArcNode *first; // 指向第一条边
} VNode, AdjList[MaxVertexNum];
typedef struct {
AdjList vertices; // 顶点数组(顺序存储,支持按编号随机访问)
int vexnum, arcnum;
} ALGraph;
// 插入无向边 (u, v):两端各插一个边结点(头插法)
void addEdge(ALGraph *G, int u, int v) {
ArcNode *p = (ArcNode *)malloc(sizeof(ArcNode));
p->adjvex = v;
p->next = G->vertices[u].first; // 头插:新结点先指向原链头
G->vertices[u].first = p; // 再让表头指向新结点,顺序不能反
ArcNode *q = (ArcNode *)malloc(sizeof(ArcNode));
q->adjvex = u;
q->next = G->vertices[v].first;
G->vertices[v].first = q;
G->arcnum++; // 一条无向边只计一次
}头插带来的副作用是链上次序与输入次序相反。这看起来是个实现细节,但它引出了邻接表最要紧的一条性质。
表示不唯一,以及它一路传导出去的后果
同一张图,边的输入次序不同、建表算法不同,链上结点的排列就不同。所以:
🔴 邻接表的表示不唯一(而邻接矩阵是唯一的)。
这一条会顺着传导下去,直接决定后面三章的题该怎么答:
- DFS/BFS 的遍历序列不唯一——每个顶点先访问哪个邻居,取决于链上次序。
- DFS/BFS 生成树不唯一——序列都变了,树自然跟着变。
- 拓扑序列不唯一——同时有多个入度为 0 的顶点时,先取哪个也是自由的。
反过来用更常出现:题目问"下列哪个不是该图的 DFS 序列"。潜台词是你可以自由选择每个顶点邻居的检查顺序,只要存在某一种顺序能生成这个序列,它就是合法的;四个选项里只有一个无论怎么选顺序都排不出来。判这类题千万别按"编号从小到大"这一种固定次序去核对,那样会把三个合法选项全判成错的。
带权图的权放在哪
权值加在边结点里(教材称 info 字段),不能放顶点结点。理由很直接:权属于"边",而一个顶点身上挂着多条边,权值各不相同,顶点结点放不下。
无向图的两个副本各存一份相同的权,所以改权时两份都要改,漏一份就会让这张图从一个方向看和从另一个方向看权值不一致。
各操作代价的来历
| 操作 | 时间复杂度 | 代价的来历 |
|---|---|---|
| 建立邻接表 | ||
| 判断 | 沿链顺序查找,链最长可达 | |
| 求出度(有向)/ 度(无向) | 数一条链的长度 | |
| 求入度(有向) | 遍历全部 | |
| 统计边数 | 扫遍所有边链表,无向图结果要除以 2 | |
| 插入一条边 | 头插 | |
| 删除一条边 | 单链表删除要先找到前驱;无向图两条链上各删一次 | |
| DFS / BFS 全图遍历 | 每个顶点入栈/入队一次贡献 | |
| 拓扑排序 | 每个顶点出队一次、每条边被松弛一次 |
最后两行都被真题单独问过,而且它们的
四顶点有向图的邻接表与逆邻接表走查(想手画一遍就展开)
有向图:
邻接表(按弧的输入次序头插,故链上次序与输入相反):
| 顶点 | 边链表(出弧) | 链长 = 出度 |
|---|---|---|
| 0 | 2 → 1 → ∧ | 2 |
| 1 | 2 → ∧ | 1 |
| 2 | 0 → ∧ | 1 |
| 3 | 2 → ∧ | 1 |
逆邻接表(链上串入弧的来源):
| 顶点 | 边链表(入弧来源) | 链长 = 入度 |
|---|---|---|
| 0 | 2 → ∧ | 1 |
| 1 | 0 → ∧ | 1 |
| 2 | 3 → 1 → 0 → ∧ | 3 |
| 3 | ∧ | 0 |
自检:出度之和 count < n 判环的场景。
边结点总数:邻接表 5 个、逆邻接表 5 个,合计 10 个。换成十字链表只要 5 个弧结点就能同时支持两个方向。
由邻接表构造逆邻接表,代价
c
void buildReverse(ALGraph *G, ALGraph *R) {
R->vexnum = G->vexnum;
R->arcnum = G->arcnum;
for (int i = 0; i < R->vexnum; i++) R->vertices[i].first = NULL;
for (int u = 0; u < G->vexnum; u++)
for (ArcNode *p = G->vertices[u].first; p; p = p->next) {
// 原图有弧 u → p->adjvex,逆图就在 p->adjvex 的链上挂一个 u
ArcNode *q = (ArcNode *)malloc(sizeof(ArcNode));
q->adjvex = u;
q->next = R->vertices[p->adjvex].first;
R->vertices[p->adjvex].first = q;
}
}
图注:左边是一个 4 顶点无向图,右边是它的邻接表。注意右边一共有 6 个边结点而图上只有 3 条边——每条无向边在两个端点的链上各存了一份。同时留意顶点表这一列是紧挨着的方格 (顺序存储、可随机访问),只有边链表才是指针串起来的。 图源:殷人昆《数据结构——用面向对象方法与 C++ 描述》(第 2 版)图8.7 无向图的邻接表表示,p356
邻接矩阵与邻接表互转,以及两个方向的代价为什么不对称
邻接矩阵 → 邻接表:遍历矩阵第
c
// 内层 j 从大到小扫:头插会让链上次序反转,倒着扫正好让链上编号升序
void MatToList(int A[][MaxVertexNum], ALGraph *G) {
for (int i = 0; i < G->vexnum; i++) {
G->vertices[i].first = NULL;
for (int j = G->vexnum - 1; j >= 0; j--)
if (A[i][j] == 1) {
ArcNode *p = (ArcNode *)malloc(sizeof(ArcNode));
p->adjvex = j;
p->next = G->vertices[i].first;
G->vertices[i].first = p;
}
}
}
void ListToMat(ALGraph *G, int A[][MaxVertexNum]) {
for (int i = 0; i < G->vexnum; i++) // 必须先清零:矩阵要为"无边"负责
for (int j = 0; j < G->vexnum; j++)
A[i][j] = 0;
for (int i = 0; i < G->vexnum; i++)
for (ArcNode *p = G->vertices[i].first; p; p = p->next)
A[i][p->adjvex] = 1;
}| 方向 | 时间复杂度 | 为什么 |
|---|---|---|
| 矩阵 → 表 | 必须看遍每一格才知道哪里有边,即使图很稀疏 | |
| 表 → 矩阵 | 清零本身就要 |
只要一端是邻接矩阵,就逃不掉
FirstNeighbor 与 NextNeighbor 这对抽象接口(读到用它们描述算法的代码时展开)
FirstNeighbor(G, v):返回的第一个邻接点,没有则返回 。 NextNeighbor(G, v, w):返回相对于 的下一个邻接点, 是最后一个则返回 。
有了它们,遍历"
c
for (int w = FirstNeighbor(G, v); w >= 0; w = NextNeighbor(G, v, w)) { /* 处理 w */ }这层抽象的价值是让同一份 DFS / BFS / 拓扑排序代码在两种存储结构上都能跑——DFS、BFS、BFS 最短路径里的算法描述用的都是这套接口。
c
// 邻接表实现:沿链走
int FirstNeighbor(ALGraph *G, int v) {
ArcNode *p = G->vertices[v].first;
return p ? p->adjvex : -1;
}
int NextNeighbor(ALGraph *G, int v, int w) {
for (ArcNode *p = G->vertices[v].first; p; p = p->next)
if (p->adjvex == w) // 先找到 w 这个结点……
return p->next ? p->next->adjvex : -1; // ……再取它的后继
return -1;
}
// 邻接矩阵实现:扫行,代价 O(n)
int FirstNeighbor_M(MGraph *G, int v) {
for (int j = 0; j < G->vexNum; j++)
if (G->edge[v][j] != 0) return j;
return -1;
}
int NextNeighbor_M(MGraph *G, int v, int w) {
for (int j = w + 1; j < G->vexNum; j++) // 从 w 之后接着扫
if (G->edge[v][j] != 0) return j;
return -1;
}两处值得注意:
- 返回
而不是 0 表示"没有了"。0 是一个合法的顶点编号,用它当哨兵会把"邻接点是 0 号顶点"误判成"没有邻接点"。循环条件写成 w >= 0正是与这个约定配套的。 - 邻接表版的
NextNeighbor写成上面这样是(每次都要重新找到 ),整趟遍历因此退化成 。实际代码里应当直接持有指针 p往下走,把整趟压回;这套接口只是为了描述算法时不依赖具体存储结构。
考点速记
三条结论,正文里都推过:
- 两级结构:顶点表顺序、边表链式;空间
,无向图边结点 个。 - 出度沿自己的链
,入度必须扫全表 ——逆邻接表与十字链表的动机。 - 邻接表不唯一,由此 DFS/BFS 序列、生成树、拓扑序列都不唯一。
这一节在真题里被考过的形式(下方「真题练习」逐题对应):
- 求某顶点入度的复杂度:正确答案是
,而选项里可能写成 ——这两个式子是同一个量,别因为形状不同就排除它。同一题的干扰项是 :它漏掉了"即使某条链是空的也得访问一次表头"这部分开销,在 的极稀疏图上不成立。 - 在邻接表上跑遍历/拓扑排序的复杂度:BFS 问过一次、拓扑排序问过一次,答案都是
,四个选项通常是 / / / 。记住来历——顶点各处理一次给 ,边各看一次给 。 - 存储选型的判断:把"存储稀疏图,用邻接矩阵比邻接表更省空间"作为错误命题,混在三选项判断题里。
- 作为最短路算法的载体:跨科目的综合题里,把一张带权网络交给你,用邻接表组织后跑 Dijkstra 求最短路径树。
易错:
里的 不能省。 写成 是错的——顶点表要占 个单元,扫全表也要访问 个表头, 时这部分开销就是全部开销。
易错:无向图的边结点是
个,不是 个。 问"这张邻接表共有多少个边结点",先看图是有向还是无向。
易错:判"某序列是不是合法的 DFS/BFS 序列"时,邻居的检查顺序可以自由选。 邻接表表示不唯一是这类题成立的前提;按"编号从小到大"一种固定次序去核对,会把合法选项误判成非法。
教材出处
- 严蔚敏《数据结构(C 语言版)》(第 2 版)印刷 p156,§6.4.2: 邻接表由"表头结点表"和"边表"两部分组成,表头结点含
data与firstarc, 边结点含adjvex、info、nextarc;同页指出 "在有向图中,第个链表中的结点个数只是顶点 的出度,为求入度,必须遍历整个邻接表", 并给出逆邻接表的做法。 - 同书印刷 p158:邻接表优缺点小节——空间复杂度
、适合稀疏图; "不便于判断顶点之间是否有边……最坏情况下要耗费 时间"; 以及"一个图的邻接矩阵表示是唯一的,但其邻接表表示不唯一"。 - 同书印刷 p164(§6.5.2 算法分析):用邻接矩阵存储时遍历为
, 用邻接表存储时为 ——本篇遍历一行的复杂度以此为准。
图源:殷人昆《数据结构——用面向对象方法与 C++ 描述》(第 2 版)图8.7,p356。
相关知识
邻接矩阵(