news 2026/9/10 5:22:38

图数据结构核心解析:存储、遍历与最短路径算法

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
图数据结构核心解析:存储、遍历与最短路径算法

1. 图是什么:先用生活场景搞懂图和图论术语

数据结构里“图”这个东西,初次接触的人往往有两种极端感受:一种觉得它不就是一堆点和线连在一起嘛,有什么好学的;另一种是被术语劝退,什么有向图、无向图、权值、度、连通分量,背了一堆名词还是不知道怎么用。我当年学的时候,属于第二种,直到后来做了几个涉及路径规划和依赖关系的实际项目,才真正把图这章吃透。

先说人话版本:图就是由顶点组成的一种结构,适合表达“多对多”的关系。你手机里的导航地图,每个路口是一个顶点,每条道路是一条边,道路的长度、拥堵程度就是边的权值。微信好友关系同样可以建模成一张图,每个人是一个顶点,两个人认识就在他俩之间连一条边。树其实也是图的一种特例,一棵树就相当于一张“没有环的无向连通图”。但图比树更自由,树有严格的父子层级,图里任意两个顶点之间都可以直接产生联系,这也是它表达能力强的根本原因。

然后再把术语逐个说清楚,方便后面对号入座:

  • 无向图:边没有方向,A和B之间的边就代表“互相有关系”,比如好友关系、道路连通。
  • 有向图:边有方向,A到B的边不一定能反向走,比如微博的关注关系、程序里模块的调用关系。
  • 带权图:每条边上带一个数值,比如距离、时间、成本、带宽。不带权的话可以认为权值都为1。
  • :无向图中顶点连接的边的数量。有向图中分为入度和出度,入度是“指向该顶点”的边的数量,出度是“从该顶点指出”的边的数量。
  • 路径与回路:从一个顶点走到另一个顶点经过的边的序列叫路径;如果路径的起点和终点是同一个顶点,就叫回路或环。
  • 连通与连通分量:无向图中,任意两个顶点之间都存在路径就叫连通图;不连通的话,拆出来的每一块就是连通分量。

这些术语别死记,你拿一张真实的城市地铁图对着看一遍就全记住了。地铁站的换乘关系是有向的还是无向的?从A站到B站通常能反向坐回来,所以是无向图;每条线路的行驶时间可以看作边的权值。你在哪一站下车能直达目的地,本质上就是在一个带权无向图里找最短路径。

图能做的事远不止这些。从网络路由到任务调度,从社交推荐到物流配送,甚至编译器里的依赖分析、代码评审里的调用链分析,底层都在用图。所以学图,学的不只是数据结构本身,更是一种把复杂关系“建模”出来的能力。

至于适合谁看,我直接说:如果你是正在学数据结构的在校生,这篇文章能帮你把图和树、数组、链表这些基础数据结构串起来;如果你是在职工程师想补算法功底,图的存储和遍历代码可以直接抄去改造;如果你是准备面试,那最短路径和拓扑排序是最高频的考点,我会把思路和代码都拆开讲。

2. 图的存储:邻接矩阵和邻接表到底怎么选

图建好了,存在内存里最常用的方案就两种:邻接矩阵和邻接表。很多人一开始纠结选哪个,其实判断标准就一条——图是稠密还是稀疏

2.1 邻接矩阵:简单直观,但空间开销大

邻接矩阵是用一个二维数组来存图。假设图里有n个顶点,就开一个n乘n的矩阵,第i行第j列的值表示顶点i到顶点j之间有没有边,有边记1,无边记0。如果是带权图,就把权值填进去,没有边的位置用一个特殊值表示,比如整数最大值。

头一次接触的人会觉得这方法实在太“笨”了,但它的优势很实在:判断任意两个顶点是否直接相连,时间复杂度是O(1),数组按下标访问就行;想遍历某个顶点的所有邻居,也只需要扫描对应的一整行。写起来也简单,二十行代码就能搞定。

代价就是空间。n个顶点需要n平方个存储单元,100个顶点就要1万个单元,1000个顶点就要100万个单元。如果一个图只有几百条边,大多数格子都是浪费的。所以邻接矩阵只适合顶点少、边很多的稠密图,比如一个班级内所有人都互相认识的关系网,或者一个区域内道路密集的路网。

2.2 邻接表:稀疏图的更优解

邻接表的思路很直接:每个顶点用一个链表或一个动态数组,把和它直接相连的邻居都记下来。整个图就是一个长度为n的数组,每个数组元素指向一个装着邻居信息的列表。

这样空间复杂度是O(V+E),V是顶点数,E是边数。边的数量远少于顶点平方的稀疏图,用邻接表能省下大把内存。实际开发里,绝大多数场景都是稀疏图:社交网络里每个人平均好友几百个,但全站用户几千万,用邻接矩阵根本存不下。所以工程上邻接表的出镜率远高于邻接矩阵。

缺点是判断两个顶点是否相连,最坏情况要把整个链表扫一遍,时间复杂度退化为O(度),不过多数场景下这个代价可以接受。

我自己的习惯是,做题和写Demo时图方便用邻接矩阵,真正做项目、处理大数据量时默认邻接表。没有绝对的好坏,只有合不合适。

2.3 Java实现:从0构建一张邻接表图

光说不练假把式,这里给出一段可以直接跑起来的Java代码,构建一张无向图的邻接表。

import java.util.*; public class Graph { private final int vertices; private final List<List<Integer>> adjList; public Graph(int vertices) { this.vertices = vertices; adjList = new ArrayList<>(vertices); for (int i = 0; i < vertices; i++) { adjList.add(new LinkedList<>()); } } public void addEdge(int u, int v) { adjList.get(u).add(v); adjList.get(v).add(u); // 无向图需要双向添加 } public void printGraph() { for (int i = 0; i < vertices; i++) { System.out.print("顶点 " + i + " 的邻居: "); for (int neighbor : adjList.get(i)) { System.out.print(neighbor + " "); } System.out.println(); } } public static void main(String[] args) { Graph graph = new Graph(5); graph.addEdge(0, 1); graph.addEdge(0, 4); graph.addEdge(1, 2); graph.addEdge(1, 3); graph.addEdge(1, 4); graph.addEdge(2, 3); graph.addEdge(3, 4); graph.printGraph(); } }

这段代码跑完,输出的是每个顶点的邻居列表。核心就是addEdge方法里做两次添加,因为无向图的边是双向的。如果你需要构建有向图,去掉第二次添加即可。如果是带权图,把List<List<Integer>>换成List<List<int[]>>,每个int[]里存两个值,一个是目标顶点,一个是权值就行。

等邻接表建好了,你就可以在这张表上做各种遍历和算法了,后面几节都会围绕这张表展开。

3. 图的遍历:BFS和DFS,从原理到代码一次搞懂

遍历是图算法的基础。树有先序中序后序,图更复杂,因为可能存在环路,所以需要一个visited数组(或者集合)记录哪些顶点已经访问过,否则会死循环。图的遍历就两种主流方案:深度优先搜索DFS和广度优先搜索BFS。

3.1 深度优先搜索DFS:一条路走到底,走不动就回头

DFS的思路就像走迷宫,从起点出发,选一条岔路走到底,遇到死胡同就退回到上一个岔路口,再换一条路走。这个“退回去再试”的过程,天然适合用递归实现,因为函数调用栈本身就帮你保存了每一步的状态。

它的应用场景非常多:判断图中是否存在从一个顶点到另一个顶点的路径;计算连通分量的个数;拓扑排序的一种实现方式;迷宫寻路;检测图中是否有环。很多回溯算法,本质都是一棵隐式的图在做DFS。

实现代码不算难,核心套路如下:

void dfs(int v, boolean[] visited, List<List<Integer>> adjList) { visited[v] = true; System.out.print(v + " "); // 访问当前顶点 for (int neighbor : adjList.get(v)) { if (!visited[neighbor]) { dfs(neighbor, visited, adjList); } } }

注意一个细节:递归深度等于图的路径长度,如果图特别大或者顶点特别多,递归层数太深可能造成栈溢出。工程上遇到这种情况,可以改成显式使用Stack的迭代版,本质一样,但能更好地控制栈空间。

3.2 广度优先搜索BFS:一层一层往外扩散

BFS的思路更像水波扩散,从起点出发,先把所有一步就能到达的顶点走完,再走两步能到达的,以此类推。由于它按层扩展的特性,天然适合求解“最短路径长度”问题,这里的“最短”指边的数量最少,在无权图中等价于最短路径。

实现BFS必须用队列,这是它的标志性特征。每访问一个顶点,就把它的未访问邻居全部入队,然后从队头取出下一个顶点继续。

void bfs(int start, boolean[] visited, List<List<Integer>> adjList) { Queue<Integer> queue = new LinkedList<>(); visited[start] = true; queue.offer(start); while (!queue.isEmpty()) { int v = queue.poll(); System.out.print(v + " "); for (int neighbor : adjList.get(v)) { if (!visited[neighbor]) { visited[neighbor] = true; queue.offer(neighbor); } } } }

BFS用的地方更多是大家熟悉的场景:社交网络里“你可能认识的人”推荐,就是先找你好友的好友,也就是距离你两层的人;爬虫程序从一个URL出发,不断抓取网页上的链接,本质上也是BFS。如果你后面要学图神经网络,BFS的分层扩散思想也会反复出现。

两种遍历的时间复杂度都是O(V+E),因为每个顶点最多入队/入栈一次,每条边最多被扫描一次。空间复杂度在最坏情况下,BFS的队列可能同时存O(V)个顶点,DFS的递归栈也最多O(V)层。区别只在于遍历顺序不同,选择哪一种,取决于你关心的是“能否到达”还是“最近几层能到”。

4. 图的应用:最短路径、最小生成树和拓扑排序

图之所以是算法面试和工程应用的重头戏,就是因为围绕它可以延伸出成体系的应用算法。这一节我挑三个最常用、最值得掌握的讲:Dijkstra最短路径、Prim/Kruskal最小生成树、Kahn拓扑排序。每一个都讲清楚“用来解决什么”“核心思路是什么”“代码怎么落地”。

4.1 Dijkstra:带权图的最短路径算法

你打开高德地图规划一条从家到公司的路线,后台跑的核心算法之一就是最短路径算法,而Dijkstra是最经典的单源最短路径算法,也就是“从一个起点到其他所有顶点的最短路径”。

Dijkstra的核心思想是贪心:每一步都从“尚未确定最短路径的顶点”中,选一个当前距离起点最近的顶点,然后把它的邻居松弛一遍。所谓松弛,就是看看“经过当前这个顶点,能不能让邻居到起点的距离更短”,如果能,就更新邻居的距离。

这里有一个前提,Dijkstra要求图中不能有负权边。道理也很简单:贪心策略一旦确定某个顶点的最短距离,就不会再回头更新它,如果后面出现一条负权边把距离拉低,贪心选出来的结果就是错的。遇到负权边,需要用Bellman-Ford算法,不过面试和工作中负权场景很少,Dijkstra完全够用。

为了高效地“取当前距离最小的顶点”,工程实现一般用优先队列(最小堆),Java里就是PriorityQueue。代码如下:

void dijkstra(int start, List<List<int[]>> adjList, int n) { int[] dist = new int[n]; Arrays.fill(dist, Integer.MAX_VALUE); dist[start] = 0; // int[]{顶点, 距离} PriorityQueue<int[]> pq = new PriorityQueue<>(Comparator.comparingInt(a -> a[1])); pq.offer(new int[]{start, 0}); while (!pq.isEmpty()) { int[] cur = pq.poll(); int u = cur[0]; int d = cur[1]; if (d > dist[u]) continue; // 过期的记录,跳过 for (int[] edge : adjList.get(u)) { int v = edge[0]; int w = edge[1]; if (dist[u] + w < dist[v]) { dist[v] = dist[u] + w; pq.offer(new int[]{v, dist[v]}); } } } }

代码里那个if (d > dist[u]) continue;是优化关键,没有它也不影响正确性,但会白白多处理很多已经过期的队列元素,图大一点性能差别肉眼可见。

4.2 最小生成树:用最少的成本连通所有顶点

普里姆算法和克鲁斯卡尔算法是两种经典的最小生成树算法,目标一致:让n个顶点通过n-1条边连通起来,并且边的总权值最小。现实场景比如:要在n个城市之间铺设通信光缆,已知每两个城市之间的铺设成本,怎么选线路总成本最低;再比如电路板上要连通所有引脚,怎么布线最短。

Prim算法的思路是从一个顶点开始,每次把一个“离当前树最近”的顶点和边收进来,直到所有顶点都进树。Kruskal算法的思路更简单粗暴:先把所有边按权值从小到大排序,然后从最小的边开始,一条一条尝试加入,只要加入后不产生环就保留。判断是否产生环,用的是并查集。

并查集如果你不熟,可以理解成“帮派合并”:每个顶点刚开始是独立的帮派,加入一条边就把两个帮派合并。如果一条边的两个端点已经在同一个帮派里,说明再加入这条边会形成环,必须跳过。

Kruskal的好处是思路直白、代码写起来不容易错,面试时更推荐优先写它。核心代码如下:

class Edge { int u, v, weight; Edge(int u, int v, int weight) { this.u = u; this.v = v; this.weight = weight; } } // 使用 Kruskal 算法计算最小生成树总权值 int kruskal(int n, List<Edge> edges) { // 按权值从小到大排序 edges.sort(Comparator.comparingInt(e -> e.weight)); int[] parent = new int[n]; for (int i = 0; i < n; i++) parent[i] = i; int totalWeight = 0; int edgeCount = 0; for (Edge edge : edges) { int rootU = find(parent, edge.u); int rootV = find(parent, edge.v); if (rootU != rootV) { // 不成环才合并 parent[rootU] = rootV; totalWeight += edge.weight; edgeCount++; if (edgeCount == n - 1) break; } } return totalWeight; } int find(int[] parent, int x) { if (parent[x] != x) { parent[x] = find(parent, parent[x]); // 路径压缩 } return parent[x]; }

用的时候把图里所有边放进List,顶点总数传进去,返回的totalWeight就是最小生成树的总权值。路径压缩这行代码别省,它能大幅降低树的深度,让find操作接近O(1)级别。

4.3 拓扑排序:有依赖关系的任务怎么排顺序

拓扑排序处理的是有向无环图,应用场景包括:大学的课程安排(学数据结构前必须先学程序设计基础)、构建系统里编译任务的先后顺序、包管理工具里依赖包的安装顺序。它的输出是一个线性序列,满足“每条边的起点都排在终点之前”。

算法上最常用的是Kahn算法,基于入度来实现:先统计每个顶点的入度,把所有入度为0的顶点入队;然后不断取出一个入度为0的顶点,把它“删除”并输出,同时把所有以它为起点的边去掉,也就是让目标顶点的入度减1;如果某个目标顶点的入度变成0,就继续入队。整个过程循环到最后,如果输出的顶点数不等于总顶点数,说明图里有环,拓扑排序是做不出来的。

List<Integer> topoSort(int n, List<List<Integer>> adjList, int[] inDegree) { Queue<Integer> queue = new LinkedList<>(); for (int i = 0; i < n; i++) { if (inDegree[i] == 0) queue.offer(i); } List<Integer> result = new ArrayList<>(); while (!queue.isEmpty()) { int u = queue.poll(); result.add(u); for (int v : adjList.get(u)) { inDegree[v]--; if (inDegree[v] == 0) queue.offer(v); } } if (result.size() != n) { System.out.println("图中存在环,无法完成拓扑排序"); return new ArrayList<>(); } return result; }

这里的关键点在inDegree数组,它需要你在构建图的时候同步统计。每加入一条u到v的有向边,就执行一次inDegree[v]++。有了拓扑排序,你就能很自然地判断一个依赖关系图是否合法,这个判断在构建系统里几乎是刚性需求。

5. 完整实操:构建一张城市交通图并跑通三种算法

前面讲了理论和代码片段,这一节我把它串成一个完整demo,就像做实验一样从头到尾走一遍。假设有6个城市,编号0到5,城市之间的道路和行驶时间如下表:

起点终点行驶时间(分钟)
0115
0230
1210
1320
2325
2435
3415
3540
4520

这是一张带权无向图。我们做三件事:构建邻接表,跑一遍从城市0出发的BFS和DFS遍历,再算一遍从城市0到其他所有城市的最短时间。

5.1 构建带权邻接表

带权图的邻接表,每个邻居要同时存两个信息:目标顶点和边的权值。代码用List<List<int[]>>实现,内层int[]的第一个元素是邻居顶点编号,第二个是权值。

List<List<int[]>> buildWeightedGraph(int n, int[][] edges) { List<List<int[]>> adjList = new ArrayList<>(); for (int i = 0; i < n; i++) { adjList.add(new ArrayList<>()); } for (int[] edge : edges) { int u = edge[0]; int v = edge[1]; int w = edge[2]; adjList.get(u).add(new int[]{v, w}); adjList.get(v).add(new int[]{u, w}); // 无向图反向也加 } return adjList; }

5.2 在图上跑BFS和DFS

由于这里主要演示带权图的最短路径,我先跑一个DFS验证图的连通性。从城市0出发,DFS访问顺序是0 → 1 → 2 → 3 → 4 → 5,因为DFS会沿着一条路走到底,1的邻居2、3依次展开,最后从4走到5。如果换BFS,访问顺序是0 → 1 → 2 → 3 → 4 → 5,看起来一样,但中间层的入队顺序不同。两个顺序不一定相同,具体取决于邻居的存储顺序。

这段代码恰好说明了无向图遍历的一个特点:在连通图里,从任意顶点出发都能访问到所有顶点;如果图不连通,就需要在外层循环里再套一层判断,对每个未访问的顶点都做一次遍历,才能统计出连通分量个数。

5.3 用Dijkstra算最短时间

把上面的带权邻接表传给第四节里的dijkstra方法,手动推一遍关键步骤:

  • 起点0:dist[1]=15,dist[2]=30,其余为无穷大。
  • 从优先队列取出距离最小的顶点1(距离15),松弛它的邻居:城市2经过1只要15+10=25,比原来的30小,更新dist[2]=25;城市3经过1需要15+20=35,dist[3]=35。
  • 继续取当前最小,依次确定城市2、3、4、5的最短距离。最终结果是:
目标城市最短时间路径
115分钟0 → 1
225分钟0 → 1 → 2
335分钟0 → 1 → 3
440分钟0 → 1 → 2 → 4 或 0 → 1 → 3 → 4
555分钟0 → 1 → 3 → 5 或 0 → 1 → 3 → 4 → 5

看到没有,如果直接用0到2的直连路是30分钟,但绕道1只要25分钟,这就是Dijkstra的价值——它比较的不是“直连”,而是“全局最短”。

实际工程里,地图导航还会在Dijkstra基础上做优化,比如加启发式策略,让搜索方向优先指向终点,而不是四面开花。但不管怎么优化,核心思想还是Dijkstra的那一套贪心+松弛框架。

6. 常见问题与排查技巧:那些年我踩过的坑

图相关的题目和项目里,有些错误特别隐蔽,代码跑起来看似正常,结果却不对。这里把我踩过和帮别人排查过的典型问题整理成一个速查表。

问题现象根本原因解决办法
遍历时死循环没有维护visited数组,或者visited标记的位置不对入队/入栈前就标记访问,不要等到出队时才标记
无向图addEdge只加了一条方向构建邻接表时漏掉反向边无向图的addEdge必须双向添加
Dijkstra结果偏大优先队列里塞入了过期的顶点记录,旧记录被重复处理if (d > dist[u]) continue;跳过过期记录
带权图用int表示无穷大时相加溢出Integer.MAX_VALUE + 权重变成负数先判断dist[u]是否等于无穷大,或改用long
拓扑排序结果为空但图看起来正常统计入度时漏掉了重复边构建图时遇到同一对顶点的多条边,入度要同步累加多次
认为邻接矩阵一定比邻接表好写顶点多、边少时,矩阵开一个大数组就内存崩溃根据V和E的比例选择,V的平方远大于E就选邻接表

除了表里这些,我再补两个经验之谈。

第一个是测试用例一定要包含环和重复边。很多人自测时用的图太“干净”,既没有环也没有重边,算法跑通了就以为万事大吉。实际上环会触发DFS的visited判断,重边会触发最短路径的更新逻辑,你必须在真实数据分布下验证过,才算真正写完。

第二个是复杂度分析一定要算在点子上。比如BFS和DFS是O(V+E),不是因为代码里有两个循环,而是因为每个顶点、每条边都只访问一次。Kruskal的复杂度是O(E log E),瓶颈在排序,并查集操作几乎可以忽略。你把复杂度分析说清楚,面试和报告里都会显得专业很多,也能帮你判断自己的代码在大数据量下能不能撑住。

图这块学起来内容确实多,从概念到存储再到算法是一条完整的链路,每层都有对应的坑。但只要像这篇文章一样,先用场景理解概念,再动手写代码,最后在真实用例里调通,你会发现图反而是数据结构里落地价值最高的一章。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/10 5:19:08

本体建模驱动的可推理知识图谱构建与大模型协同实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/10 5:18:44

TVBoxOSC 电视盒子适配实测:三档支持、出问题先查哪里

TVBoxOSC 电视盒子适配实测&#xff1a;三档支持、出问题先查哪里 【免费下载链接】TVBoxOSC TVBoxOSC - 一个基于第三方项目的代码库&#xff0c;用于电视盒子的控制和管理。 项目地址: https://gitcode.com/GitHub_Trending/tv/TVBoxOSC 盒子里装好应用&#xff0c;打…

作者头像 李华
网站建设 2026/9/10 5:17:26

Linux权限模型与加固自查:从原理到实践,理解隔离与安全

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华