p38(16.1 分钟)、p39(14.7 分钟)、p40(13.2 分钟)、p41(14.2 分钟)、p42(26.8 分钟)
图是什么
与线性结构、树的对比
数组、链表、栈、队列 → 线性数据结构树 → 非线性数据结构(层次结构)图 → 另一种非线性数据结构对照着看树的特殊性:
N 个节点的树必定恰好有 N−1 条边从根出发必须能到达所有节点,且根到某节点只有唯一一条路径图则没有支配节点间连接的规则,边可以任意方式连接——树只是图的一种特殊类型。
形式定义
图 G = (V, E)V 是顶点集,E 是边集这是一个「有序对」(第一个对象必须是顶点集,第二个必须是边集)有序对 (a, b):顺序重要,(a,b) ≠ (b,a)(除非 a = b),写成圆括号无序对 {a, b}:顺序不重要,{a,b} = {b,a},写成花括号边的两种类型
| 有向边 | 无向边 | |
|---|---|---|
| 连接 | 单向 | 双向 |
| 表示 | 有序对 (u, v) | 无序对 {u, v} |
| 图形 | 带箭头的边 | 普通边 |
| 要表示 v→u | 必须另画一条边 (v, u) | 同一条边即包含 |
有向图:所有边均为有向边;无向图:所有边均为无向边。
一张图同时含两种边是可能的,但这里不研究这类图。
无向图可以重绘为有向图(每条无向边 → 两条方向相反的有向边);有向图不一定能重绘为无向图。
建模应用(四个例子)
| 场景 | 图类型 | 为什么 |
|---|---|---|
| 社交网络(Facebook) | 无向、未加权 | 友谊是相互的 |
| 万维网/互联网 | 有向、未加权 | 「页面 A 链到 B 不代表 B 链回 A」 |
| 城际公路网 | 无向、加权 | 假设高速双向通行,长度不同 |
| 市内道路网 | 有向、加权 | 城市里有单行道 |
推荐好友问题
给拉玛推荐「朋友的朋友」:拉玛有 3 个朋友(埃拉、鲍勃、凯蒂),鲍勃有 3 个朋友(汤姆、萨姆、里)、凯蒂有 2 个朋友(里、斯瓦蒂)→ 共可推荐 4 位用户(里被算了两次)。
用图论术语表述:找出所有「到给定节点的最短路径长度等于 2」的节点。
网络爬虫本质上就是图遍历。
加权图
有些连接不能同等看待(道路长度不同),给每条边关联权重/代价。
例:城市 A 到 D 选最佳路线。
不计权重:经 B、C 的路线与经 E、F 的路线都是 3 条边,「同样好」 经 E 的黄色路线只有 2 条边,「最好」计入权重:需要把路径上各边权重相加算总成本 → 最短(最优)路线变成经 B 和 C 的那条可以把所有图都视为加权图;未加权图可看作所有边权重相同的加权图,通常假定权重为 1。
基本属性
记号
|V| 表示顶点数量|E| 表示边的数量(集合的基数,用与绝对值相同的记号)特殊类型的边
自环(self-loop) 一条边只涉及一个顶点、两个端点相同 例:网页指向自己的链接(点击页眉链接只是刷新)多重边(multi-edge) 同一条边在图中出现不止一次 例:城市间航班网络(一对城市间有多趟航班)自环和多重边会让处理图的工作变复杂。
简单图(simple graph):不含自环也不含多重边。这里大多研究简单图。
边数的取值范围
最少边数 = 0(可以不画任何边,节点可以完全不相连)。
简单图的最大边数:
| 最大边数 | 取值范围 | |
|---|---|---|
| 有向图 | N × (N−1) | [0, N(N−1)] |
| 无向图 | N × (N−1) / 2 | [0, N(N−1)/2] |
无向图是一半的原因:无向图中一对节点之间只能有一条双向边,不能有方向不同的两条边。
具体数值:
有向图顶点数 = 10 → 最大 90 条边有向图顶点数 = 100 → 最大 9900 条边→ 最大边数近似于顶点数量的平方密集图 vs 稀疏图
| 定义 | 存储 | |
|---|---|---|
| 密集图(dense) | 边数接近可能的最大边数,约为顶点数量的平方量级 | 通常用邻接矩阵 |
| 稀疏图(sparse) | 边数确实很少,通常接近于顶点数量、且不超过这个数量 | 通常用邻接表 |
两者没有明确界限,完全取决于具体情境;但这是重要分类——很多决策基于图是密集还是稀疏。
路径相关术语
这一块最容易写漏。
路径(path) 一系列顶点序列,其中每一对相邻顶点之间都有边相连简单路径(simple path) 没有重复顶点(顶点不重复则边也不会重复)漫步(walk) 允许重复顶点/边轨迹(trail) 顶点可以重复但边不能重复的漫步图论中「路径」的用法不统一。大多数时候说「路径」就指简单路径;如果允许重复,用「漫步」这个术语。
所以:路径 = 顶点或边均不重复的漫步。
一个关键断言:两个顶点之间如果存在一条有重复的漫步,那么必然也存在一条简单路径。
因此寻找简单路径最有意义。这里的约定是:说「路径」即指简单路径;若不是简单路径会明确说明。
连通性
| 术语 | 适用 | 定义 |
|---|---|---|
| 连通 | 无向图 | 任意一个顶点到其他任意顶点都存在路径 |
| 强连通 | 有向图 | 任意一个顶点到其他任意顶点都存在路径 |
| 弱连通 | 有向图 | 不是强连通,但把所有边视作无向边后变成连通图 |
无向图就说「连通」,有向图就说「强连通」,记住这两个就够了。
反例:某图中能从 A 到 C,但不能从 C 到 A → 非强连通。
应用:市内道路网应当始终是强连通的。
环
闭合行走(closed walk):起点和终点是同一个顶点,且长度必须大于 0长度:路径中边的数量简单环(simple cycle):闭合行走,且除起点/终点顶点外没有其他顶点或边被重复无环图(acyclic):不含环的图通常说「环」指简单环。
树 = 无向无环连通图的实例:
树中可以有闭合行走(边的重复),但不存在简单环此外树还必须连通(无向无环图不只有树这一种)有向无环图 = DAG:不存在「始于且终于同一个顶点、长度大于 0」的路径。
图中的环会给「从一个顶点到另一个顶点找最短路线」这类算法设计带来诸多问题。
三种表示法
表示法一:边列表
核心设计:建两个列表——一个存所有顶点,一个存所有边。
顶点列表:仅仅是一个名字/字符串的列表。
边对象:一条边由其两个端点确定 → 定义为具有两个字段的结构体:起始顶点、终止顶点。
字段命名约定:
| 图类型 | 字段命名 | 原因 |
|---|---|---|
| 无向图 | 第一顶点 / 第二顶点 | 顺序不重要 |
| 有向图 | 起始顶点 / 终止顶点 | 顺序很重要,FH 与 HF 是两种不同连接 |
加权图 → 在边对象中再加一个字段存权重。
内存优化:
问题:若边对象前两个字段存字符串,各行长度不同,每行占用内存不一样
优化 1:字段存「指向顶点列表中那些名称的引用/指针」 → 每行内存相同,且引用比名称副本省很多开销
优化 2(更好的做法):只存顶点在顶点列表中的「索引(整数)」 → 边对象的两个字段变成两个整数字段 → 边列表中每一行消耗的内存量都相同空间复杂度:
顶点列表:O(|V|)边列表(用索引/引用设计):O(|E|)整体:O(|V| + |E|)想在图的内存存储上做得比这更好很难。
时间复杂度才是问题所在:
| 操作 | 做法 | 复杂度 |
|---|---|---|
| 找某节点的所有邻居 | 必须扫描整个边列表做线性搜索 | O(|E|) |
| 判断两个节点是否相连 | 同样要在线性搜索,最坏看所有条目 | O(|E|) |
有向图只看起始节点;无向图起点和终点都要看。
为什么 O(|E|) 被认为很贵:
|E| 最坏 ≈ V²任何以边数为量级运行的操作都被认为代价很高我们希望把成本控制在「与顶点数量同量级」O(V) 比 O(E) 好得多表示法二:邻接矩阵
核心设计:把边存在二维数组/矩阵中,大小 V × V。
每个顶点有一个 0 到 V−1 的索引(从顶点列表中取索引:A=0、B=1、C=2…)无权图:A[i][j] = 1 表示存在从 i 到 j 的边,否则为 0无向图 vs 有向图的矩阵:
| 矩阵性质 | 每条边填几个位置 | |
|---|---|---|
| 无向图 | 对称(A[i][j] = A[j][i]) | 两个位置(只需看一半) |
| 有向图 | 不对称 | 一个位置(必须遍历整个矩阵) |
时间复杂度:
| 操作 | 条件 | 复杂度 |
|---|---|---|
| 找邻居 | 给的是名称 | O(|V|)(先扫顶点列表找索引 O(|V|) + 扫一行 O(|V|)) |
| 找邻居 | 已知索引 | O(|V|)(省掉扫顶点列表) |
| 判断两节点是否相连 | 已知索引 | O(1) ✓ |
| 判断两节点是否相连 | 给的是名称 | O(|V|) |
关键在于:邻接矩阵判断相连不是无条件 O(1),给名称时还要先扫顶点列表。
优化:用额外内存建一个哈希表,名称和索引作为键值对,则「名称→索引」也变成 O(1)。
总评:用邻接矩阵,最常执行操作的时间成本与顶点数量成量级,而非与边数量成量级。
加权图的邻接矩阵:
A[i][j] 直接存该边的权重不存在的边要设一个默认值,例如一个非常大或最大可能的整数值(图中填的是「无穷」)「这种值绝不会被预期成为边的权重」空间代价:
邻接矩阵确切使用 V² 个单位的内存 → 空间复杂度 O(V²)矩阵不仅存了「相连」的信息,还存了「未相连」的冗余信息示例图具体数字:边列表只消耗 10 行内存,而邻接矩阵消耗 64 个单位(8×8)。
Facebook 的具体演算:
假设有 10 亿(10⁹)用户平均一名用户的朋友数不超过 1000连接总数 = 10⁹ × 1000 = 10¹²无向图要除以 2(否则每条边算两次)→ 5 × 10¹¹ 条边这远远少于顶点数量的平方(10¹⁸)
邻接矩阵需要 10¹⁸ 个存储单元 ≈ 10¹⁸ 字节 ≈ 1000 PB(拍字节) → 绝不可能装在一块物理磁盘上邻接表只要 5 × 10¹¹ 字节 = 0.5 TB(太字节) → 如今一台典型个人电脑就会有这么大的存储空间结论:大多数真实世界的图都是稀疏的。邻接矩阵对多数频繁操作运行时间好,但空间效率不高,因此并不适用。
表示法三:邻接表
核心思路:只保留「相连」的信息,摒弃「未相连」的信息(因为后者可以推导出来)。
不再用「数组索引代表边终点、值表示是否有边」,而是直接保存一个「所有与之相连的节点的列表/集合」。
语义上是反过来的:索引不再代表任何含义,值才是我们所连接到的节点的实际索引(与矩阵的语义正好相反)。
示例图的邻接表(8 顶点):
0 号节点连 1、2、31 号节点连 0、4、52 号节点连 0、6…7 号节点有 4 个连接(3、4、5、6)
各行的连接数分别是:0 号 3 个、1 号 3 个、2 号 2 个…… 每行大小都不同如何用编程实现「每行不同大小」:
C/C++:创建一个「指针数组」,大小为 8 每个指针指向一个不同大小的一维数组 第 0 个指针 → 大小 3 的数组 第 7 个指针 → 大小 4 的数组空间复杂度:
无向图:2 × |E| 个单位内存有向图:|E| 个单位内存严格表述是 O(|V| + |E|):因为存储顶点也会占用一定内存;但假如可以假定顶点数量相较于边数量少得多,就可以简单说与边的数量有关。能正确计数总是好事。
时间复杂度对比:
| 操作 | 邻接矩阵 | 邻接表 |
|---|---|---|
| 判断两节点是否相连 | O(1) ✓ | O(V)(无法直接定位,必须逐行扫描) |
| 找某节点的所有邻居 | O(V) | O(V)(最坏情况一行可能有 V 个单元) |
邻接表的优化:若保持每行有序,可改用二分搜索,成本降到 O(log V),对数运行时间确实很棒,但始终维持数组有序在其他方面代价很高。
稀疏图下的实际性能:
社交网络有 10 亿(10⁹)用户任何人拥有朋友的最大数量是 1 万(10⁴)机器每秒能扫描/读取 10⁶ 个单元| 操作 | 邻接矩阵 | 邻接表 |
|---|---|---|
| 找给定节点的所有邻居 | 扫一整行 = 10⁹ 个单元 → 1000 秒 ≈ 16.66 分钟 ✗ | 最多 10⁴ 个单元 → 10 ms ✓ |
| 判断两个节点是否相连 | 读一个单元 → 1 微秒 ✓ | 必须扫整行 → 10 ms |
设计一个社交网络该选哪种结构?机器不能让用户等 16 分钟。
结论:对大多数真实世界的图,邻接表更好——既省空间又省时间。但前提是图必须是稀疏的。
动态增删边:
| 邻接矩阵 | 邻接表(数组实现) | |
|---|---|---|
| 插入一条新边 | 只需把某格的 0 改成 1 → O(1) | 要往那一行加一个元素,但动态增加现有数组的大小是不可能的 → 必须新建数组 + 复制旧内容 + 抹去旧数组 ✗ |
| 删除一条边 | 把 1 改成 0 → O(1) | 同样麻烦 |
用数组实现动态或可变列表很棘手,这种「新建数组 + 复制旧数据」的做法代价很高,这正是我们常常改用链表存储动态/变化列表的确切原因。
邻接表的链表实现:
每行改成一个链表:不再需要动态改数组大小,插入和删除容易得多
实现:为每个节点创建一个链表存储其邻居 创建一个「指针数组」,唯一区别是每个指针指向一个「链表的头部」 链表节点有两个域:一个存数据,一个存下一个节点的地址 加权图 → 在节点里再加一个字段存权重这种「把一个节点的邻居信息存储在链表中」的结构,正是通常所说的邻接表。
这里要分清什么是什么:指针数组的元素是指向节点的指针(只存地址);而带两个字段的那个才是节点。
一个延伸问题:在邻接表里添加/删除连接有多灵活?有没有办法改进?如果不用链表存储邻居,而是用二叉搜索树,会怎样?
会更好,因为查找、插入、删除一个邻居的时间成本都会降到对数级。
三种表示法完整对比
| 操作 | 边列表 | 邻接矩阵 | 邻接表 |
|---|---|---|---|
| 找某节点的所有邻居 | O(|E|) | O(|V|) | O(|V|) 最坏(有序时 O(log V)) |
| 判断两节点是否相连 | O(|E|) | O(1)(仅当已知索引;给名称则 O(|V|)) | O(|V|) 最坏 |
| 增删边 | 麻烦 | O(1) ✓ | 数组实现麻烦,链表实现容易 |
| 空间 | O(|V|+|E|) | O(V²) | O(|V|+|E|)(无向 2|E|、有向 |E|) |
| 适合 | 简单存储 | 密集图 | 稀疏图 ✓ |
坑
- N 个节点的树恰好 N−1 条边,图没有这个限制;树是图的特例。有向图最大边数 N(N−1),无向图 N(N−1)/2,差一倍。
- 「路径」默认指简单路径;允许重复的叫「漫步」,边不重复的叫「轨迹」。无向图说「连通」,有向图说「强连通」,用词要区分。
- 邻接矩阵判断相连不是无条件 O(1),给名称时要先扫顶点列表;它的空间是 O(V²),稀疏图会浪费大量内存(Facebook 要 1000 PB)。
- 邻接表的「找邻居」最坏也是 O(V),只有稀疏图才有实际优势;索引不代表含义,值才代表连接的节点(与矩阵语义相反)。
- 邻接表用数组实现时增删边很麻烦(数组不能动态增长),所以实际用链表。大多数真实世界的图都是稀疏的,邻接表通常是更好的选择。
请输入编辑凭据,只有站点所有者可以修改文章。