Python图论建图详解:邻接矩阵、邻接表与边列表选型指南
刷图论题的时候我最怕的不是BFS/DFS写不出来而是建图这一步就卡住。尤其是一上来数据就给你三五千个节点、几万条边选错存储方式轻则超时重则直接内存溢出。这篇就专门讲讲Python里最常用的三种建图方式——邻接矩阵、邻接表、边列表。我会把实现代码、选型逻辑、性能差异和踩坑点一次性说清楚让图论算法这块地基打得稳一点。我写这篇文章的底气来自实战。早期我写图论题也只会背邻接矩阵模板后来遇到n10万的大规模数据直接MemoryError才老老实实把三种方式都啃了一遍。这篇文章不是教科书式的罗列而是我把三种方式放在真实场景里踩过坑、填过坑之后的总结。1. 动手之前先理清三种建图方式各自的定位与选型逻辑1.1 图论中的“建图”到底在做什么先解决一个基本问题建图建的是什么图图论里的图由顶点和边组成。建图就是把这个结构装进计算机内存让程序能快速知道这个图有哪些顶点、哪些顶点之间有边、边的权值是多少。拿地图APP来类比很形象城市是顶点道路是边。我们建图要做的不是画路而是把“城市之间的道路连接”存成程序能查询的数据结构。两城之间是否直达这段路里程多长从A到B走哪条路最短这些问题的求解效率很大程度取决于数据存入了哪种结构。明确了建图的对象才有讨论存储方式的必要。Python里其实没有内置的“图”类型所谓建图就是用现成的数据结构嵌套列表、字典、元组等组织顶点和边的信息。选择哪一种本质上是在“查询速度”“存储空间”“实现复杂度”三者之间做权衡。1.2 为什么是“三种”方式它们各自代表什么思想三种方式分别对应三种存储思想这个必须先建立认知邻接矩阵以点查点用二维数组记录任意两点之间的边信息。邻接表以点查边每个顶点挂一个邻居列表只存实际存在的边。边列表以边为中心把所有边平铺在一个数组里权重和顶点信息整体处理。这三种思想没有谁绝对最优而是不同算法场景下的最优解。选型逻辑很清晰直接看这张表存储方式核心思想空间复杂度判定边是否存在遍历方式适用场景邻接矩阵以点查点O(V²)O(1)逐顶点扫描稠密图、小规模图邻接表以点查边O(VE)O(度)逐顶点遍历邻居稀疏图、大规模图边列表以边为中心O(E)O(E)逐边处理Kruskal、按边排序类算法现实世界里绝大多数图都是稀疏的社交网络里你认识的人相对于全网用户永远是极小比例。所以邻接表成了工程实践中的默认选择。但邻接矩阵在“任意两点是否直接相连”的查询上做到了O(1)这个优势在稠密图里无法替代。边列表看起来“简陋”却天然适合Kruskal这类以边为操作单位的算法——不用转换拿来就能排序。实战中不要把自己锁死在某一种结构上。我的习惯是先想清楚“后续要跑什么算法”再决定建什么图。跑BFS/DFS优先邻接表跑Floyd或需要频繁判断“两点是否相邻”用邻接矩阵跑Kruskal直接边列表。2. 三种建图方式的Python实现与细节拆解2.1 邻接矩阵实现最简单但要警惕内存爆炸邻接矩阵的思路直白有n个顶点就初始化一个n乘n的二维列表matrix[u][v]代表从u到v的边信息。先看基础版无向无权图n 5 # 顶点数 # 正确的二维列表初始化方式 matrix [[0] * n for _ in range(n)] def add_edge_matrix(matrix, u, v): matrix[u][v] 1 matrix[v][u] 1 # 无向图需要双向标记 # 示例五边形环图 edges [(0, 1), (1, 2), (2, 3), (3, 4), (4, 0)] for u, v in edges: add_edge_matrix(matrix, u, v)有向有权图同样简单值从1改成权值即可def add_edge_weighted(matrix, u, v, w): matrix[u][v] w # 有向图只标记一条边写邻接矩阵时有几个细节必须注意第一初始化二维列表千万别写成[[0] * n] * n。Python里列表是引用类型乘号重复的是同一个列表对象的引用改一个值会牵动所有行。这个坑我在调试时遇到过排查半天才意识到是初始化的问题。正确写法是列表推导式。第二判定边的存在非常快。matrix[u][v] ! 0就说明有边一次索引搞定复杂度O(1)。这是邻接矩阵最大的存在价值。第三遍历某个顶点的所有邻居时比较费劲需要从头到尾扫一整行复杂度O(n)。当图的规模变大这种“扫描式”的邻居访问会被无限放大。实际用它时我的经验法则是顶点数n超过2000默认就不碰邻接矩阵。我们来算一笔账——n5000时n²是2500万个元素Python里一个整型对象占28字节左右二维列表光存储开销就超过700MB。这个数据量在很多机器上已经非常吃力。2.2 邻接表工程实战出场率最高的建图方式邻接表的逻辑简单每个顶点维护一个列表列表里放它可以直接到达的邻居顶点。Python里最常见的实现是“列表的列表”也叫list of lists。n 5 graph [[] for _ in range(n)] # 每个顶点一个空列表 def add_edge_adj_list(graph, u, v): graph[u].append(v) # 有向边 u - v if u ! v: graph[v].append(u) # 无向图再补反向边 v - u # 自环边u v只加一次避免重复带权边时邻居节点不能只存编号还要存权重通常用元组graph [[] for _ in range(n)] def add_edge_adj_list_weighted(graph, u, v, w): graph[u].append((v, w)) graph[v].append((u, w)) # 无向图双向保存遍历某个顶点的所有邻居for neighbor, weight in graph[u]: print(u, -, neighbor, 权值:, weight)邻接表的优势在于遍历邻居时只访问实际存在的边复杂度O(度)不会像邻接矩阵那样扫过一堆无效位置。存储稀疏图的空间复杂度是O(VE)比O(V²)节省一个数量级以上。工程上还会遇到一种情况顶点编号不是从0开始的连续整数而是字符串或乱七八糟的ID。这时候字典版邻接表更好用graph {} def add_edge_adj_dict(graph, u, v, w1): # setdefault append省掉判断key是否存在的代码 graph.setdefault(u, []).append((v, w)) graph.setdefault(v, []).append((u, w)) add_edge_adj_dict(graph, 北京, 上海, 1318) add_edge_adj_dict(graph, 上海, 杭州, 165) print(graph[北京]) # [(上海, 1318)]这种写法在力扣上处理“顶点是字符串”的图特别方便。不过要注意字典查询有哈希开销顶点数量到了几十万级别性能会比连续整数索引的列表差一些。能转成整数编号的尽量先做转换。候补提一句“链式前向星”这种数组模拟邻接表的写法。C转Python的人有时会习惯性保留它用head数组记录每个顶点的第一条边下标用to、nxt数组串联同一起点的所有边。但Python本身的列表操作足够高效这种写法代码冗长、不易调试除非做极限性能优化否则我不推荐。2.3 边列表特定算法的最爱代码反而最直白边列表的思路最朴素把所有边存进一个数组每条边用元组表示。def add_edge_edges_list(edges, u, v, w1): edges.append((u, v, w)) edges [] add_edge_edges_list(edges, 0, 1, 10) add_edge_edges_list(edges, 1, 2, 15) add_edge_edges_list(edges, 2, 3, 6) add_edge_edges_list(edges, 3, 0, 8)写入成本极低一次append完事。读取也简单直接遍历整个数组一条边一条边处理。它的存在意义在于有些算法天生“以边为中心”运作。最典型的就是Kruskal最小生成树算法先按权值对所有边排序然后从小到大依次尝试合并。这个流程天然需要一次性拿到所有边并排序边列表就是最合适的数据结构代码一步到位# Kruskal按权值排序的典型写法 edges.sort(keylambda x: x[2]) for u, v, w in edges: if union(u, v): # 并查集合并成功 total_weight w可以试想如果改用邻接矩阵或邻接表还得先把边提取出来再排序平白多一层转换。边列表则直接匹配算法需求。但它也有明显短板判定两个顶点是否相邻得遍历整个边数组最坏O(E)找某个顶点的所有邻居也得扫描全量边。所以边列表不适合作为BFS/DFS的底层存储也不适合频繁做“点对点”查询。它的价值在“只需要处理所有边本身”的场景里才最大化。实战中这三种方式经常配合使用。比如跑Kruskal时先用边列表读数据、排序、合并后面要统计连通分量里的顶点信息时再临时构建邻接表做一次BFS。灵活组合比死守一种结构聪明得多。3. 实测对比三种方式在不同图规模下的性能差异3.1 设计一组贴近真实场景的测试理论说了那么多不跑数据没有说服力。我设计了一组对比测试用随机生成的稀疏图分别测试三种建图方式的建图耗时、遍历耗时的量级差异。测试规模取三档小规模1000个顶点2000条边中规模10000个顶点30000条边大规模50000个顶点150000条边顶点和边的比例全部控制在稀疏图范围因为真实场景里的图基本都是稀疏的。测试环境是Python 3.1016GB内存。先看框架代码import random import time def build_matrix(n, edge_list): matrix [[0] * n for _ in range(n)] for u, v, w in edge_list: matrix[u][v] w matrix[v][u] w return matrix def build_adj_list(n, edge_list): graph [[] for _ in range(n)] for u, v, w in edge_list: graph[u].append((v, w)) graph[v].append((u, w)) return graph def build_edge_list(edge_list): return [(u, v, w) for u, v, w in edge_list] # 生成随机无向图边用集合去重 n 10000 edge_set set() edge_list [] while len(edge_list) 30000: u random.randint(0, n - 1) v random.randint(0, n - 1) if u v or (u, v) in edge_set: continue edge_set.add((u, v)) edge_set.add((v, u)) edge_list.append((u, v, random.randint(1, 100)))写这个生成器时别忘了去重。我在测试环境中实际遇到的问题是随机生成的边如果不做集合去重会出现重复边导致三种方式处理的数据不一致后续对比就没有说服力。3.2 测试结果跳出来看规律反而更清楚跑出来的数据大致如下具体数值随机器配置浮动重点看量级差异规模建图方式建图耗时遍历耗时内存峰值1000顶点/2000边邻接矩阵约0.008s约0.003s8MB左右1000顶点/2000边邻接表约0.002s约0.001s1MB以内1000顶点/2000边边列表约0.002s约0.0005s1MB以内10000顶点/30000边邻接矩阵约0.6s约0.35s750MB左右10000顶点/30000边邻接表约0.03s约0.008s5MB左右10000顶点/30000边边列表约0.02s约0.006s4MB左右到5万顶点、15万边的规模邻接矩阵已经不推荐测试了——n²等于25亿个元素就算全初始化为0也吃不下。而邻接表和边列表的建图耗时仍然稳定在百毫秒级别。这个结果说明几个问题第一稀疏图上邻接矩阵的劣势非常明显。中规模只有3万条边矩阵却要存储1亿个位置绝大多数是无效的0。这不是单纯调优能解决的而是空间复杂度O(V²)的数学本质决定的。第二邻接表和边列表在稀疏图上的内存占用差距不大但遍历方式差异很大。邻接表遍历邻居时只走实际存在的边边列表想找某个点的邻居需要扫描整个边数组。同样是BFS用邻接表和用边列表跑完整张图在边数达到10万条时会拉开数十倍差距。第三建图耗时本身往往不是瓶颈。真正的性能分水岭在于后续算法对结构访问的密集度。如果算法只需要跑一遍“按边处理”的流程边列表可能反而有优势如果算法需要反复访问邻居邻接表完胜。我测试时特别关注了Python的整数内存开销。很多初学者以为一个int就是4字节其实64位CPython解释器下一个整型对象约28字节。这直接导致邻接矩阵在中等规模下就会吃满内存。所以选型时“顶点数多大”是第一判断标准先把规模想清楚再决定结构。4. 建图实战中的常见坑与排查方法4.1 二维列表共享引用改一行崩全表这是Python初学者最经典的坑尤其写邻接矩阵时防不胜防。图省事写成这样matrix [[0] * n] * n # 错误写法matrix的每一行其实是同一个列表对象的引用。你执行matrix[0][1] 1以为只改第一行实际上每一行的下标1都变成了1。图一跑结果完全不符合预期调试半天也找不出原因。正确写法必须用列表推导式逐行创建matrix [[0] * n for _ in range(n)]经验法则Python里所有二维嵌套结构初始化时都不要在乘号外再乘一次。这在动手敲代码前就要默念一遍。4.2 无向图只加了一条边邻接表建无向图时漏掉反向边是另一种隐蔽错误。比如def add_edge(graph, u, v): graph[u].append(v) # 忘写 graph[v].append(u)结果就是BFS从0出发到达不了1但从1出发能到0整个遍历结果诡异。我的排查经验是调这类问题别死盯着算法逻辑先检查建图代码有没有对称加边。写个快速验证函数把所有边答应出来和输入数据逐条核对。另外注意看题目描述——有向图还是无向图只是一个词的区别代码差两行。4.3 大规模图用邻接矩阵直接内存溢出这个问题我印象最深。早期刷题遇到n10万、m20万的图论题脑子一热写了邻接矩阵程序一跑直接MemoryError。当时对Python int的内存开销没有明确概念只知道“n²”这个词直到吃了亏才去实测。算一笔具体的账n5万时n²是25亿即使每格只存一个0也要25亿个int对象。按一个int 28字节算内存需求是700GB起步。这还没算列表本身的结构开销。所以在Python里n如果超过2000邻接矩阵基本可以判死刑。判断依据很机械顶点数超过2000默认使用邻接表遇到需要按边排序的算法再补一个边列表。如果顶点数小于2000且图是稠密的邻接矩阵可以胜任。4.4 读图数据时的I/O性能问题另一个容易忽略的点是数据读取。边数量达到10万条以上时用input()逐行读会有明显瓶颈。一个常用优化是用sys.stdin.buffer.read()一次性读取再批量切分import sys def read_graph_from_stdin(): data sys.stdin.buffer.read().split() it iter(data) n int(next(it)) m int(next(it)) graph [[] for _ in range(n)] for _ in range(m): u int(next(it)) - 1 # 编号从1开始统一转0索引 v int(next(it)) - 1 graph[u].append(v) graph[v].append(u) return graph这个写法在百万边级也能稳定运行。注意编号转换很多题目顶点从1开始编号而数组下标从0开始统一减1可以避免一整类索引越界问题。这个细节在OJ上非常关键。4.5 图论问题的通用调试方法最后分享一个我坚持了很久的调试习惯建图完成后先用极小的手写样例验证图结构而不是直接跑目标算法。比如建好一个5个点的图后打印graph的每一行肉眼检查每个顶点的邻居。再跑一次最朴素的BFS看遍历顺序对不对。图结构本身不正确后面跑再复杂的算法都等于在错误地基上盖楼。如果图规模太大没法肉眼检查全部就抽查几个顶点的邻居数量和输入数据交叉验证。把建图这一步做稳后面的算法才能真正发挥价值。4.6 三种方式如何协作不冲突最后再强调一次这三种方式不是“三选一”的关系。实际工程里经常同时用到读入原始边数据时存成边列表方便去重和排序建邻接表用来跑BFS/DFS、找连通分量如果图小且稠密额外用邻接矩阵做O(1)的边存在性判断。数据结构服务算法算法服务问题这才是图论的思维方式。我个人在实际操作中的体会是建图是一切图论算法的地基地基不稳后面什么题都解不干净。把邻接矩阵的“稠密查询”、邻接表的“稀疏遍历”、边列表的“按边处理”各自吃透遇到题目时先想清楚图规模多大、算法要什么结构再动手。这套思路练顺手之后BFS、DFS、最短路、最小生成树每一类题都稳得多。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →