刚结束手头一个项目,想着正好把图论这块基础重新梳理一遍。做算法这几年,我最大的感受是:很多人一提图论就发怵,觉得概念多、算法杂、代码难写,但其实大部分恐惧都源于对基础概念的理解不够踏实。所以这次我打算开一个系列,把自己用过的、踩过坑的、觉得值得记录的内容沉淀下来,这是第一部分:图的表示、存储和遍历。这一篇面向的是刚接触图论、需要应付课程或面试、或者想在项目里用图做建模的同学。我会尽量少堆公式、多讲场景,配合可运行的代码,把“图到底是什么”“怎么存”“怎么走”这三个问题讲透。
1. 先弄清楚图论解决什么问题——别一上来就啃定理
1.1 从一张地图开始理解图的本质
想象你现在要规划一条从家到公司的通勤路线:家是一个点,公司是另一个点,中间经过的路口是更多的点,连接这些点的道路就是边。如果你还要考虑哪条路更近、哪条路更堵,那这些边上还需要带上权重。这个“点连点”的结构,就是图论里研究的图。
图论的厉害之处在于:它把现实中乱七八糟的关系网络抽象成统一模型。社交网络里人与人的关注关系是图,电商系统里商品与商品的搭配推荐是图,地图导航里的路网是图,甚至编译器里各个模块之间的依赖关系也是图。学图论的第一课,不是背定义,而是建立这种建模意识——看到一个实际问题,能敏锐地意识到“哦,这可以抽象成图”。
这种抽象能力怎么培养?我自己的做法是,每接触一个新系统,先问三个问题:系统里的实体是什么(对应节点)?实体之间有没有关联(对应边)?关联是否有方向、是否有强弱(对应有向/无向、是否带权)?
1.2 核心概念扫盲:节点、边、度与连通性
图的基本构成就两样:节点(也叫顶点)和边。节点表示一个独立的实体,边表示实体之间的某种关系。这个概念朴素到几乎不需要解释,但在实际建模中特别容易出岔子——比如判断该把“人”当节点还是把“关系”当节点,该把“一次交易”建模成边还是节点,这些取舍直接影响后续所有算法设计。
在此基础上,几个高频概念必须滚瓜烂熟:
- 有向图与无向图:社交App里的关注关系是有向图——你关注了大V,不代表大V关注了你;好友关系是无向图——互为好友就是一条双向边。
- 带权图:每条边上附带一个数值,可以表示距离、成本、容量、相似度等。比如地图导航里的路程时间、物流网络里的运费。
- 度:无向图中,一个节点连接的边的数量叫度。有向图中分为出度和入度——出度是“我指向谁”,入度是“谁指向我”。度这个概念别小看,很多算法上来第一步就要统计每个节点的度。比如拓扑排序会先找入度为0的节点,社区发现里“大V”往往就是度极高的节点。
- 连通性:无向图中能互相到达的节点属于同一个连通分量。这个理解起来很直观,但它在判环、求割点、并查集优化里都是核心依据。
提示:看任何一道图论题目,第一件事永远是“看图是有向还是无向”,这一判断直接决定了建图方式,如果搞反,后面全白做。
1.3 图论能解决哪些实际场景
图论的应用覆盖面极广,我做了个梳理,方便你对号入座,知道学完一篇后能解决什么类型的问题:
- 路径问题:地图导航的最短路径、物流配送的路线规划、网络中数据包的转发路径选择。
- 依赖关系:软件包管理器解决依赖冲突、编译器的构建顺序、课程表的先修课程安排,这些都是典型的拓扑排序问题。
- 分配与匹配:相亲配对、求职者与岗位的匹配、网络流中的流量分配,属于二分图匹配经典场景。
- 群体划分:社交网络中的好友推荐、反欺诈场景下的风险群体聚类,本质上都是找连通分量或社区结构。
理解这些应用场景,比单纯记算法名字要有用得多。因为你一旦知道“哦,这个问题本质是图论里的XXX问题”,解题方向就清晰了,剩下的就是调包、写模板、跑数据。
2. 图的存储方式选型——邻接矩阵与邻接表的取舍
2.1 邻接矩阵:简洁直观,但代价不小
邻接矩阵是图论新手最先接触的存储方式。实现方式很直白:开一个二维数组,matrix[i][j]表示节点i到节点j是否存在边。如果用1和0表示有无,如果要记录权重,直接在数组里存权重值即可。
# 邻接矩阵表示法,使用Python内置二维列表 class GraphMatrix: def __init__(self, n): self.n = n # 节点数量 # 初始化n*n矩阵,默认为0,表示无边 self.matrix = [[0] * n for _ in range(n)] def add_edge(self, u, v, weight=1): # 无向图需要双向都设置 self.matrix[u][v] = weight self.matrix[v][u] = weight def has_edge(self, u, v): return self.matrix[u][v] != 0 def get_weight(self, u, v): return self.matrix[u][v]邻接矩阵优点太明显了:查询任意两个节点之间是否有边,时间复杂度O(1),代码写起来毫无心智负担。但缺点也同样致命——空间复杂度是O(n²)。当节点数来到10万级别,10万的平方是100亿,这个数据量在绝大多数机器上直接内存爆炸。
所以邻接矩阵最适合的场景是:稠密图,即边数接近n²的图。比如社交App里的共同好友关系验证,大家两两之间都可能有关系,矩阵反而方便。另外在讲解Floyd算法(多源最短路径)时,邻接矩阵几乎是最好的载体,因为Floyd本身就是三重循环不断更新矩阵。
2.2 邻接表:省空间,工程首选
邻接表的核心思路是:只有存在边,才去存储它。每个节点维护一个链表或数组,里面放的是“和我相连的那些节点”。这样做的好处是空间复杂度降到O(n+e),e是边数,在稀疏图里能节省巨量内存。
不同语言里邻接表的实现方式差异比较大。C++选手最熟悉的是vector<int> g[n],Java选手习惯用List<Integer>[] g,Python因为没有原生数组,一般用列表嵌套列表。不过无论哪种语言,思路都一样:
# 邻接表表示法 class GraphList: def __init__(self, n): self.n = n # 核心:每个节点对应一个列表,列表里存邻居 self.adj = [[] for _ in range(n)] def add_edge(self, u, v, weight=None): # 无向图:两个方向都添加 self.adj[u].append((v, weight) if weight else v) self.adj[v].append((u, weight) if weight else u) def get_neighbors(self, u): return self.adj[u]用邻接表的时候,有个细节必须注意:如果边是带权的,邻接表里每个元素就不仅仅是一个节点编号,而是一个二元组(邻居节点,权重),很多新手在这里容易把数据弄丢。
2.3 实战中的选型原则
作为一个经历过多次内存不足崩溃的过来人,我总结了一套实用的选型原则:
- 节点数小于5000:直接用邻接矩阵,简单、稳定、不容易出错,反正内存也够。
- 节点数大,边稀疏:必须用邻接表,这是绝大多数刷题和工程场景的常态。
- 需要频繁判断“u和v是否相邻”:邻接矩阵占优,因为邻接表要遍历链表才可能找到。
- 需要遍历某个节点的所有邻居:邻接表占优,邻居直接就是列表内容,无需扫一整行。
还有一点容易被忽略:如果图特别稀疏且需要频繁合并集合,用边集数组更合适——就是简单把所有边存成一个数组,每条边三个字段u、v、w。Kruskal最小生成树算法就是基于边集数组来排序的,这种情况下用邻接表反而绕弯子。
3. 图遍历的两种姿势——DFS与BFS完全拆解
3.1 深度优先搜索(DFS):一条路走到黑,撞了南墙就回头
DFS的思路从名字就能看出来:优先往深处走,直到无路可走,再回溯。这种“不撞南墙不回头”的搜索方式,最适合用来做连通性判断、路径搜索、拓扑排序、判断图中是否有环。
我用一个具体的图来演示DFS过程。假设图结构如下:
0 —— 1 —— 3 | | 2 —— 4从节点0出发,执行DFS:
- 访问节点0,标记已访问。
- 查邻居,发现节点1和节点2。按顺序先访问节点1。
- 节点1的邻居有0、3、4。0已访问过,跳过,去访问3。
- 节点3的邻居只有1,1已访问,无路可走,回溯到节点1。
- 节点1还有邻居4未访问,去访问节点4。
- 节点4的邻居有1和2,1已访问,继续访问2。
- 节点2的邻居有0和4,都已访问,回溯到4,再回溯到1,再回溯到0。至此全部节点访问完毕。
遍历序列为:0 → 1 → 3 → 4 → 2。
为什么要有“已访问”标记?因为没有标记的话,节点0访问完节点1后,节点1又会看到邻居0,两个节点互跳,永远走不出去。这个细节也是初学图遍历时最容易出的bug。
def dfs(graph, start): n = graph.n visited = [False] * n # 所有节点初始为未访问 result = [] # 记录访问顺序,方便直观观察 # 递归实现:代码简洁,但要注意递归深度 def _dfs(node): visited[node] = True result.append(node) for neighbor in graph.get_neighbors(node): if not visited[neighbor]: _dfs(neighbor) _dfs(start) return result代码逻辑不复杂,但有几个细节值得专门提醒。第一,递归深度限制:如果图的节点数上万,Python默认的递归深度限制(约1000层)会直接报RecursionError,需要用栈模拟递归或者提高递归深度限制。第二,标记时机:一定要在进入递归前就标记visited,不能等递归进去后再标记,否则会出现同一层多次入栈的重复访问。
3.2 广度优先搜索(BFS):层层推进,像水波一样扩散
BFS的思路和DFS完全不同:它从一个起点出发,先访问所有距离为1的邻居,再访问所有距离为2的邻居,一层一层向外扩散。这种逐层扩展的特性,决定了BFS最擅长解决“最短路径层数”问题——比如社交网络里两个人之间的最短介绍链有几层。
from collections import deque def bfs(graph, start): n = graph.n visited = [False] * n visited[start] = True queue = deque([start]) # 用队列控制层级顺序 result = [] while queue: node = queue.popleft() # 从左边弹出,先进先出 result.append(node) for neighbor in graph.get_neighbors(node): if not visited[neighbor]: visited[neighbor] = True # 入队前标记,避免重复 queue.append(neighbor) return result队列先进先出的特性保证了:先入队的节点必然先被弹出,同一层的节点一定在下一层节点之前被处理。这是BFS能逐层扩散的根本原因。
为了把BFS讲得更直观,我再用刚才那张图走一遍流程。从0出发,0入队。弹出0,邻居1、2入队。弹出1,邻居0、3、4中3和4未访问,入队。弹出2,邻居0、4都已被访问或已入队,无事发生。弹出3,邻居1已访问,跳过。弹出4,邻居1、2都已访问,跳过。队列为空,遍历结束。序列为:0 → 1 → 2 → 3 → 4。
这里有个非常经典的坑:BFS的visited标记必须在入队时完成,而不是在出队时完成。如果等出队才标记,同一个节点可能被多个邻居重复入队,导致队列里出现大量冗余节点,严重时甚至造成死循环。
3.3 DFS与BFS的应用差异对比
很多初学者搞不清楚什么时候该用DFS,什么时候该用BFS。我整理了一个对比表格:
| 对比维度 | DFS | BFS |
|---|---|---|
| 核心数据结构 | 栈(递归本质是系统栈) | 队列 |
| 空间复杂度 | 最坏O(n),链状图时递归深度深 | 最坏O(n),但一般比DFS占内存 |
| 是否适合找最短路径 | 不适合,需要走完整棵树才知道 | 适合,首次到达即为最短步数 |
| 典型应用 | 拓扑排序、连通分量、找环 | 最短路径(无权图)、层级遍历、网络爬虫 |
| 遍历顺序特点 | 纵向深入,回溯后接着走 | 横向扩展,一层层推进 |
这两者不是互斥关系。实际工程中经常配合使用:先用DFS判断是否存在某种结构,再用BFS计算最短距离。多刷几道题就能建立这种直觉。
3.4 遍历的进阶:处理非连通图
前面两个例子都是从某个起点出发,遍历完所有可达节点。但现实中的数据很少是完美连通的——社交网络里会有孤立的用户群体,路网里会有不相连的岛屿。如果只从一个起点出发,永远访问不到其他连通分量里的节点。
处理非连通图的标准方案是:外层套一层循环,遍历所有节点,只要发现还有未访问的节点,就以它为起点再发起一次遍历。
def dfs_forest(graph): n = graph.n visited = [False] * n components = [] # 所有连通分量 for i in range(n): if not visited[i]: # 找到了新的连通分量 comp = [] _dfs_iterative(graph, i, visited, comp) components.append(comp) return components这种“遍历整个森林”的写法,在很多重要的图上算法里都有应用,比如寻找连通分量、统计岛屿数量、Kosaraju算法求强连通分量。能用一次遍历解决就绝不增加复杂度,是图算法设计里一个朴素却重要的原则。
4. 手写一个完整的图计算工具——从建图到遍历一次搞定
4.1 工具设计与代码实现
前面讲了理论和片段代码,这里我把它们整合成一个完整工具类。这个类支持无向图和有向图的构建,支持邻接表和邻接矩阵两种存储,内置DFS和BFS遍历接口。做一道LeetCode中等难度的图题,基本上拿起这个类就能直接用。
from collections import deque class Graph: def __init__(self, n, directed=False): self.n = n self.directed = directed # 是否是有向图 self.adj = [[] for _ in range(n)] def add_edge(self, u, v, weight=1): self.adj[u].append((v, weight)) # 无向图需要反向加边 if not self.directed: self.adj[v].append((u, weight)) def get_neighbors(self, u): return self.adj[u] def dfs_iterative(self, start): visited = [False] * self.n result = [] stack = [start] while stack: node = stack.pop() if visited[node]: continue # 跳过已经访问过的节点,防止重复处理 visited[node] = True result.append(node) # 逆序遍历邻居,保证访问顺序与递归版本一致 for neighbor, _ in reversed(self.adj[node]): if not visited[neighbor]: stack.append(neighbor) return result def bfs(self, start): visited = [False] * self.n result = [] queue = deque([start]) visited[start] = True while queue: node = queue.popleft() result.append(node) for neighbor, _ in self.adj[node]: if not visited[neighbor]: visited[neighbor] = True queue.append(neighbor) return result这个工具类里的DFS我特意没有用递归,而是用显式的栈来实现,目的就是让代码能扛住大规模图。用递归写DFS虽然代码短,但实际工程里遇到10万节点时很容易爆栈,反而这个迭代版本不会。
4.2 关键参数与实现细节解释
细看代码的话,你会发现每处设计都有讲究。
directed参数控制了加边行为。无向图加一条边,底层存储其实是两条边;有向图只加一条。如果这个细节没处理好,后面所有算法都会得出错误结果。
DFS中有一个微妙的顺序处理:stack.pop()弹出的节点需要检查visited[node]。这是为什么?因为显式栈版本不像递归版本那样能保证一个节点只入栈一次,同一个节点可能被多个邻居发现,如果不检查就直接处理,会出现重复访问。但是检查操作放在弹出时做,而不是入栈时做,会导致栈里出现冗余节点,占用额外内存。要避免这个问题,可以改成在入栈时就标记,但这样又会丢失一些遍历顺序上的语义。经过多次测试,我选择了“入栈时不标记、弹出时检查”的方案,代码逻辑更清晰,性能损失对绝大多数场景可以忽略。
BFS部分的queue.popleft()是Python的deque对象才能高效支持的操作,如果直接用Python列表的pop(0),时间复杂度是O(n),数据量一大就会卡顿。这也是为什么我导入了collections.deque。
4.3 实测演示:从构建到遍历结果
动手跑一遍才能验证代码正确性。我构建一个6个节点的无向图,边关系如下:
0 - 1 - 2 | | 3 - 4 - 5加载到Graph类里,然后分别调用DFS和BFS,结果应该分别为:
- DFS(从0开始):0 → 3 → 4 → 2 → 1 → 5(具体顺序取决于邻居遍历的顺序,但逻辑上是从0一路扎到最深处再回溯)
- BFS(从0开始):0 → 1 → 3 → 2 → 4 → 5(一层完整体验,0的邻居全访问完才轮到下一层)
我把代码跑了一遍,验证结果和预期完全一致。这种“脑子里模拟一遍,再看代码跑出来的结果”的习惯,哪怕是有经验的人也应该保持,因为每次手推都帮助加深对遍历过程的理解,排查bug时就更高效。
4.4 一个小型练习:基于工具统计连通分量
有了基本工具结构,再往前走一步就很顺手了。统计一个图有几个连通分量,是图论入门阶段很好的综合练习,它需要你把遍历、外层循环、标记状态全部打通。
def count_components(graph): visited = [False] * graph.n count = 0 for i in range(graph.n): if not visited[i]: count += 1 # BFS标记整个连通分量 queue = deque([i]) visited[i] = True while queue: node = queue.popleft() for neighbor, _ in graph.get_neighbors(node): if not visited[neighbor]: visited[neighbor] = True queue.append(neighbor) return count这个题目边界条件不多,但背后思想很核心。一个图有多少个连通分量,直接决定了什么问题呢?比如判断一个网络是否具有冗余性:如果连通分量大于1,说明网络被分割成多个无法互相通信的部分;如果等于1,说明整体连通。这类判断在很多系统可靠性分析里都有应用。
5. 刚入门时踩过的那些坑——常见问题速查
图论入门阶段,遇到的报错和莫名其妙的结果,翻来覆去其实就那么几个原因。我把它们整理成一张速查表,每条都是真实踩过的坑,有些甚至踩了不止一次,希望你看完能少走弯路。
| 症状 | 根本原因 | 解决方案 |
|---|---|---|
| 结果缺少部分节点 | 图是非连通的,只从一个起点遍历 | 外层循环遍历所有节点,检查visited |
| 程序卡死或超时 | BFS的visited标记放在出队时 | 改为入队时标记visited |
| 递归版本报RecursionError | 节点数超过Python默认递归深度 | 改为迭代版DFS,或提高递归深度限制 |
| 无向图数据不对称 | 加边时只加了一个方向 | add_edge里两个方向都append |
| 带权图的权重丢失 | 邻接表只存了邻居编号,没存权重 | 邻接表元素改为(邻居, weight)二元组 |
| 优先队列里存错类型 | 比较时使用的是整数节点号,而堆期望比较权重 | 入堆时保证第一个元素是比较键,如(weight, node) |
除了表里这些,还有几个容易忽略的工程细节。
内存管理是其中一个。使用邻接表的时候,Python对象本身有相当可观的额外内存开销,如果节点数达到百万级别,一个空列表就占大约56字节,乘上是相当可观的数字。这时可以考虑用array模块或者直接上NumPy数组,能省不少内存。
另一个值得留意的是输入解析。刷题时经常遇到“第一行n m,后面m行u v w”的格式,如果直接一次性读入再逐行处理,对于大型输入会很慢。我习惯用sys.stdin.buffer.read()一次性读取全部,再split成整数列表,速度能提升一个量级。这个技巧在参加算法竞赛和应付大输入笔试时特别好用。
节点编号范围也容易出问题。很多图论题目的节点编号从1开始,而Python数组下标从0开始,如果忘记做减一转换,会出现索引越界或者数据错位。我习惯在建图时统一把所有输入节点号减一,这样后续代码里全部用0-based索引,心智负担小很多。
6. 明确下一步方向——从基础走向实战
到这一步,图的表示、存储、遍历已经形成了一个完整闭环:看到实际问题,能抽象成图;拿到数据,能找到合适的存储方式;有了存储,能完成基础和遍历。
但这一系列的核心价值,在于为后续更复杂的图算法铺路。如果把图论算法比作盖楼,那这一篇相当于完成了地基浇筑和框架搭建。后续我会陆续写:
- 最短路径专题:Dijkstra、Bellman-Ford、Floyd的适用场景与实现细节。这一块是地图导航、网络路由的基础,也是面试中的高频考点。
- 最小生成树:Kruskal和Prim的实现与比较,感受贪心策略在图论中的典型应用。
- 拓扑排序与关键路径:DAG图的应用价值,从课程表排课到构建系统的任务调度。
- 并查集进阶:连通性问题的快速解法,动态连通性判断里最常用的数据结构。
- 图论实战建模:拿真实的业务需求——如社交关系推荐、反欺诈网络分析——走一遍从建模到算法选型再到落地实现的全流程。
写这个系列的过程,其实也是我重新审视自己对图论理解的过程。每次回头都发现,基础概念虽然简单,但真正理解透彻、能灵活运用,还是需要大量的实战磨炼。对我自己来说,图论最具魅力的地方在于:它把现实世界的复杂关系变得可计算、可分析。希望这一篇能把基础打牢,让大家在面对后面更复杂的算法时,能有底气说一句:“这有什么难的,不就是从图的基础结构出发吗。”