二叉树的线索化:
线索二叉树找前驱/后继
根据已经构建出来的线索二叉树 找到一个节点的后序或者前驱
使用中序线索二叉树进行中序遍历 (利用线索实现非递归算法 空间复杂度度为O(1))
思路 :使用一个for循环 p的初值是以t为根节点找到最左边的子树 每循环一次就让p等于中序的下一个节点 直到p=null
使用中序线索二叉树进行逆向中序遍历
使用for循环 第一个找到当前根节点的最右边的节点 然后找它的中序前驱
先序线索二叉树找先序后继
如果p有左孩子 则p的先序后继为左孩子 如果没有左孩子 先序后继为右孩子
先序线索二叉树找先序前驱
先序遍历中 左右子树中的结点只可能是根的后继不可能是前驱 所以给定一个结点 如果按照之前的方法只能用土办法从头开始先序遍历
改用三叉链表可以找到父节点 三叉链表就是多一个指向父节点的指针
如果当前结点是左子树 那么先序的前驱是他的父节点
如果当前的结点是右子树 且当前的父节点没有左子树 那么先序前驱就是父节点
如果当前的结点是右子树 且当前的父节点有左子树 根左右 那么先序前驱是父节点的左子树中最后一个被先序遍历的结点
如果p是根节点 则p没有先序前驱
假设有右孩子
后序线索二叉树的后序前驱就是右孩子
假设没有右孩子
一定有左孩子 后序线索二叉树的后序前驱就是左孩子
如果当前的结点是右孩子 后序后继就是父节点
如果当前结点是左孩子 且父节点没有右孩子 后序后继就是父节点
如果当前的结点是左孩子 且父节点有右孩子 后序后继就是以右孩子为根节点的后序遍历的第一个结点
树 的存储结构
概览
使用双亲表示法:用数组顺序存储各个结点 每个结点中保存数据元素,指向双亲结点(父节点的指针)即存放父元素在数组中的下标
也可以用这个方法存储森林 让根节点存储的下标为-1即可 即它不指向任何的父元素
优缺点 找父元素方便 找孩子不方便
使用孩子表示法: 用数组顺序存储各个结点 每个结点中保存数据元素,孩子链表头指针 ,即数组中的每个元素由数据元素和一个链表组成 链表中每个结点内容是孩子结点在数组中的位置 以及下一个孩子的指针
优缺点 找孩子方标 缺点 找双亲结点不方便
使用孩子兄弟表示法
数据结构和二叉树类似 采用二叉链表实现 每个结点内保存数据元素和两个指针 区别在于左指针存储左孩子 右指针存储右兄弟结点
树森林与二叉树的转换
树转二叉树 森林转二叉树 技巧就是想象把整棵树提起来
二叉树转树 二叉树转森林 技巧就是根据层序每层恢复
树 森林的遍历
树的遍历
森林的遍历
树的先根遍历: 若树非空 先访问根节点 再依次(使用while循环) 对每棵子树进行先根遍历
树的先根遍历序列与这颗二叉树的先序序列相同
树的后根遍历: 若树非空 先依次(使用while循环) 对每棵子树进行后根遍历 再访问根节点
树的后根遍历序列与这颗二叉树的中序序列相同
层序遍历:和二叉树的层序遍历一样
森林的遍历 : 先序遍历等同于依次对二叉树的先序遍历 树的先根遍历
中序等同于依次对二叉树的中序遍历 树的后跟遍历
哈夫曼树
在含有n个带权叶节点的二叉树中 其中带权路径长度(wpl)最小的二叉树 称为哈夫曼树 也叫最优二叉树
并查集
就是使用双亲表示法存储树或者森林
涉及两种操作
并查集的终极优化
压缩路径: find 操作 先找到根节点 再将查找路径上的所有结点都挂到根节点下
每次find操作 先找到根 再压缩路径 可使树的高度不超过O(α(n)) α(n)是一个增长很缓慢的函数
不超过4
目标
图的概念
图G由顶点集V和边集E组成,记为G = (V, E),其中V(G)表示图G中顶点的有限非空集;E(G)
表示图G中顶点之间的关系(边)集合。若V = {v1, v2, … , vn},则用|V|表示图G中顶点的个数,也称图G的阶,E = {(u, v) | uÎV, vÎV},用|E|表示图G中边的条数
注意:线性表可以是空表,树可以是空树,但图不可以是空,即V一定是非空集
对于无向图 顶点的度指得是依附于该顶点的边的条数 一个边会被计算两次 所以即无向图的全部顶点的度的和等于边数的2倍
对于有向图 顶点的度等于入度和出度之和
在有n个顶点 e条边的有向图中 入读之和=出度之和=e
要搞清楚一些概念:
路径: 顶点1到顶点n之间的顶点序列
回路: 第一个顶点和最后一个顶点相同的路径
简单路径: 一个顶点不重复出现
简单回路:除了第一个顶点和最后一个顶点之外其他的顶点不重复出现
路径长度: 路径上变得数目
点到点的距离: 存在路径就是路径长度 不存在就是无穷
无向图点和点有路径: 则称点之间是联通的
有向图中点和点双向都有路径: 两个顶点式强连通的
无向图:
连通图至少有n-1条边
如果是计算非连通最多的边数
可以先想象让一个顶点孤立出来 剩下n-1条边全部连满
有向图
强连通图至少有n条边 形成回路
子图和生成子图
子图就是原来图的一部分顶点 和一部分边 但是只要子图包括了边 就要把这条边的所有顶点加进来
生成子图 顶点集合和原图完全一模一样,只允许删边,不能删顶点。
对比普通子图:普通子图可以删顶点;生成子图严禁删顶点,仅删边。
例子:生成树,就是连通无向图的一个生成子图,并且恰好 条边、连通、无环。
无向图中的极大连通子图称为连通分量
子图必须连通,且包含尽可能多的顶点和边 意思是这个无向图本身可以是非连通的
性质:
连通分量一定是连通子图,但连通子图不一定是连通分量。 连通子图只要是连通的子图就行
一句话:只要还能把外面顶点吸进来并且保持连通,那它就不是连通分量。
有向图中的极大强连通子图称为有向图的强连通分量
定义:极大强连通子图 = 强连通分量
生成树和生成森林
定义:连通图的生成树,是包含全部顶点的一个极小连通子图
一、生成树(连通无向图)
定义:连通图的生成树,是包含全部顶点的一个极小连通子图
极小连通子图:想要保持连通,边已经少到不能再少。少任意一条边就不再连通。
n 个顶点的生成树:严格只有 条边。两个核心性质(选择题高频)
删除生成树任意 1 条边 → 图不再连通。
往生成树上任意加 1 条原图的边 → 必然产生回路 (环)
生成树不是唯一的
二、生成森林(非连通无向图)
定义:非连通图,每个连通分量各自取一棵生成树,组合在一起,整体就是生成森林。
假设原图 n 个顶点,一共有 k 个连通分量。
每个连通分量顶点数 ,
每个分量生成树边数:
总边数:
带权值
几种特殊形态的图
2)有向完全图
任意一对顶点:同时存在两条方向相反的弧 ,。n 个顶点:总边数
。边范围:。
稀疏图 & 稠密图
稀疏图:边的数量很少;一般判定标准:。适合邻接表存储。
稠密图:边数量很多,接近完全图。适合邻接矩阵存储
树——不存在回路,且连通的无向图
有向树——一个顶点的入度为0、其余顶点的入度均为1的有向图,称为有向树(边方向一般从根向外。我们数据结构学的普通树,存储画出来就是这种有向树)
配套公式速记卡
n 顶点连通无向图生成树:边数 =
n 顶点,k 个连通分量,生成森林边数 =
n 顶点无向完全图边数:
n 顶点有向完全图边数:
n 顶点无向树:连通 + 无环 + 条边;边 > n‑1 必有环。
邻接矩阵法
邻接表
记住数据结构的定义
#define MaxVertexNum 100 //顶点最大数量
typedef struct{
char Vex[MaxVertexNum]; //顶点表,存各个顶点数据
int Edge[MaxVertexNum][MaxVertexNum]; //邻接矩阵,0/1表示边
int vexnum,arcnum; //当前图的顶点数、边/弧数
}MGraph;
邻接矩阵是顺序存储实现图的方式,用一个二维数组来描述顶点之间的边 / 弧关系,分为无权图、带权图(网)两种场景,同时区分无向图、有向图。
无向图的矩阵是对称阵
求顶点的度 入度 出度
无向图 顶点那一行或者那一列的非零元素的个数
有向图
出度 :顶点那一行的非零元素个数
入度 : 顶点那一列的非零元素个数
带权图(网)的邻接矩阵
数据结构定义
#define MaxVertexNum 100
#define INFINITY 0x7fffffff //代表无穷大
typedef char VertexType;
typedef int EdgeType; //权值类型
typedef struct{
VertexType Vex[MaxVertexNum];
EdgeType Edge[MaxVertexNum][MaxVertexNum];
int vexnum,arcnum;
}MGraph;
带权就是原本矩阵存的是1 或者0 现在要存权值 如果不连通这两个顶点之间就是无穷
对于使用邻接矩阵法这种方法存储图 适合稠密图 因为时间复杂度是顶点数的平方 和边数没有关系
性能分析
空间复杂度:,只取决于顶点数量,和边的数量无关。
哪怕图只有很少几条边,二维数组也要开辟 个单元。
适用场景:稠密图(边数很多,接近 )。
如果是稀疏图(边很少),邻接矩阵会浪费大量空间,此时优先邻接表。
无向图优化:邻接矩阵对称,可以只存储上三角或者下三角,压缩一半存储空间。
有一个缺点是增加删除顶点很麻烦 因为二维数组大小固定
邻接矩阵法的性质
邻接矩阵幂运算 逐句拆解
核心定理: = 顶点 i 到顶点 j,路径长度恰好为 n 的路径总条数
就是原邻接矩阵: 代表 i 到 j 有 1 条长度 = 1 的路径(直接一条边)。
矩阵乘法刚好等价 “路径拼接”
比如 A的平方=B 那么B【1】【4】 就等于 A的第一行和A的第一列的点积
因为要找 顶点1到4 路径为长度为2的所有路径 这个内积会把
1->1->4
1->2->4
1->3->4
1->4->4
全部轮一遍
邻接表是图的链式存储方案
typedef struct VNode{
VertexType data; //存顶点的数据(A/B/C)
ArcNode *first; //指向第一条边结点的指针
}VNode, AdjList[MaxVertexNum];
typedef struct ArcNode{
int adjvex; //这条边指向哪个顶点的数组下标(核心!只存下标,不存顶点数据)
struct ArcNode *next; //同顶点下,下一条边
//InfoType info; //带权图,存权值,无权图不用
}ArcNode;
完整图结构体:
typedef struct{
AdjList vertices; //顶点数组
int vexnum,arcnum; //顶点数、边数
}ALGraph;
空间复杂度(高频选择题)
无向图
每一条边(u,v),要存 2 个边结点:u 链表挂 v、v 链表挂 u。
边结点总数 =
总空间复杂度:
有向图
弧<u,v>只在 u 的链表存 1 次边结点,v 链表不会自动生成。
边结点总数 =
总空间复杂度:
✅适用场景:稀疏图(边很少),节省大量空间;稠密图不适合,边太多链表开销大。
非常重要的性质:邻接表表示不唯一;邻接矩阵唯一
核心操作时间复杂度
痛点:邻接表天生方便看出边,不方便看入边。如果做题需要频繁求入度,要额外建逆邻接表。
邻接表:遍历 u 对应的边链表,看有没有结点adjvex == v。最坏。
对比邻接矩阵:直接访问
A[u][v],。
👉考点区分:查边是否存在,邻接矩阵占优;遍历邻接点,邻接表占优。
图的广度优先遍历
计算空间复杂度
始终记住 1.使用邻接矩阵存储 O(V的平方) 2. 使用邻接表存储O(V+E)
// 主流程:初始化visited数组,循环遍历所有顶点
for(i=0; i<G.vexnum; ++i)
visited[i]=FALSE; // 访问标记数组初始化
InitQueue(Q); // 初始化辅助队列Q
for(i=0; i<G.vexnum; ++i) // 从0号顶点开始遍历
{
if(!visited[i]) // 对每个连通分量调用一次BFS
BFS(G,i); // vi未访问过,从vi开始BFS
}
// 广度优先遍历函数
void BFS(Graph G, int v){ // 从顶点v出发,广度优先遍历图G
visit(v); // 访问初始顶点v
visited[v]=TRUE; // 对v做已访问标记
EnQueue(Q, v); // 顶点v入队列Q
while(!isEmpty(Q)){
DeQueue(Q, v); // 顶点v出队列
// 遍历v的全部邻接点
for(w=FirstNeighbor(G,v); w>=0; w=NextNeighbor(G,v,w))
{
//检测v所有邻接点
if(!visited[w]){ // w为v的尚未访问的邻接顶点
visit(w); // 访问顶点w
visited[w]=TRUE; // 对w做已访问标记
EnQueue(Q, w); // 顶点w入队列
}//if
}
}//while
}
关键要点(408 考点)
visited[]全局数组,记录顶点是否被访问,初始全部为FALSE。FirstNeighbor(G,v):求 v 的第一个邻接点;NextNeighbor(G,v,w)求 v 相对于 w 的下一个邻接点,是图的标准接口。for循环:处理非连通图,每遇到一个没访问的顶点,开启一轮 BFS,调用 BFS 的次数就是连通分量的个数。广度优先生成树核心性质
408 选择题易错判断
图的深度优先遍历
bool visited[MAX_VERTEX_NUM]; //标记数组,记录顶点有没有被访问
void DFSTraverse(Graph G){ //【总入口函数,专门处理非连通图!】
//第一步:全部顶点标记为未访问
for(v=0;v<G.vexnum;++v)
visited[v]=FALSE;
//遍历所有顶点,遇到没访问过的顶点,就启动一次DFS
for(v=0;v<G.vexnum;++v)
if(!visited[v])
DFS(G,v);
}
//递归函数:从顶点v出发深度遍历
void DFS(Graph G,int v){
visit(v); //1.访问当前顶点v
visited[v]=TRUE; //2.打上已访问标记,防止重复走、死循环
//遍历v所有邻接点w
for(w=FirstNeighbor(G,v);w>=0;w=NextNeighbor(G,v,w)){
if(!visited[w]){ //邻接点w没访问过,递归深入
DFS(G,w);
}
}
}
二、时间复杂度(分存储方式,408 必考)
:顶点总数;:边总数
一句话记忆:邻接矩阵看顶点平方;邻接表看顶点 + 边之和。
** 三、空间复杂度(来自递归调用栈)**
DFS 是递归实现,空间开销是函数调用栈的深度
补充考点:
- 递归版 DFS 空间主要消耗是递归栈,visited 标记数组属于额外空间,一般也算在空间开销,但选择题核心考递归栈。
- 可以手动用栈非递归实现 DFS,逻辑一样,本质还是栈,最坏空间依旧。
图的遍历与图的连通性
对⽆向图进⾏BFS/DFS遍历调⽤BFS/DFS函数的次数=连通分量
对于连通图,只需调⽤1次 BFS/DFS
对有向图进⾏BFS/DFS遍历
调⽤BFS/DFS函数的次数要具体问题具体分析
若起始顶点到其他各顶点都有路径,则只需调⽤1次BFS/DFS 函数
对于强连通图,从任⼀结点出发都只需调⽤1次 BFS/DFS
最小生成树
对于⼀个带权连通⽆向图G = (V, E),⽣成树不同,每棵树的权(即树中所有边上的权值
之和)也可能不同。设R为G的所有⽣成树的集合,若T为R中边的权值之和最⼩的⽣成树,则T称为G的最⼩⽣成树
(Minimum-Spanning-Tree, MST)
• 最⼩⽣成树可能有多个,但边的权值之和总是唯⼀且最⼩的
若图中顶点数为n,则它的生成树含有 n-1 条边。对生成树而言,若砍去它的一条边,则会变成非连通
图,若加上一条边则会形成一个回路。
Prim 算法(普里姆算法)
从一个顶点出发,每次选连接 "已选集合" 和 "未选集合" 的最小权边,把新顶点拉进来。
类比:你已经建好了一个城市集群,每次挑一条最便宜的路,把一个新城市连进集群
算法步骤
补充具体算法实现步骤
需要建立两个数组一个是isjoin数组 表示当前的顶点是否在选中的集合中
第二个是lowcost 单表当前结点加入树的最低代价 一开始只有和第一个顶点直接连接的顶点才有值 其他的为∞
第一轮 遍历当前lowcost数组 找到值不为无穷 且值最低的,还没加入树的顶点
然后把isjoin中对应的顶点置为true
再次循环遍历 更新还没有加入的各个顶点的lowcost更新 保证现有的树到其他顶点的值最小
‼️‼️ 注意lowcost里面的最小值不是起始顶点到各个顶点的最小值 而是当前的树到其他顶点的最小值
第二轮 和上一轮一样 过程都包括从lowcost中选择最低的 更新isjoin数组 更新lowcost
时间复杂度分析
每⼀轮处理:循环遍历所有个结
点,找到lowCost最低的,且还没
加⼊树的顶点。
再次循环遍历,更新还没加⼊的各个顶点的lowCost
每轮的时间复杂度o(2n)
总时间复杂度O(n²)
普里姆适合用于边稠密图
Kruskal 算法(克鲁斯卡)
具体算法实现步骤
初始将各条边按照权值排序
第一轮 检查第一条边的两个顶点是否连通(是否属于同一个集合)————》使用并查集 find(v1),find(v2)
共执行e轮 每轮需要判断两个顶点是否属于同一个集合
时间复杂度分析(豆包)
最小生成树(MST):整体要解决的问题
给定无向连通带权图,选出一个子图:
注意区分最短路径算法
最短路径
最短路径问题
BFS算法单源最短路径
求顶点u到其他顶点的最短路径
需要两个数组
算法过程
// 求顶点 u 到其他顶点的最短路径(无权图BFS)
void BFS_MIN_Distance(Graph G,int u){
// 初始化距离数组d、前驱数组path
for(i=0;i<G.vexnum;++i){
d[i] = ∞; // 初始化路径长度为无穷大
path[i] = -1; // 初始化前驱顶点为-1(无前置顶点)
}
d[u] = 0; // 起点到自身距离为0
visited[u] = TRUE; // 起点标记为已访问
EnQueue(Q,u); // 起点u入队列
// BFS主循环:队列非空就持续处理
while(!IsEmpty(Q)){
DeQueue(Q, u); // 队头元素出队,赋值给u(u被更新为当前处理顶点)
// 遍历u的全部邻接点w
for(w=FirstNeighbor(G,u);w>=0;w=NextNeighbor(G,u,w)){
if(!visited[w]){ // 如果w还没有被访问过
d[w] = d[u] + 1; // w的最短距离 = 当前顶点距离+1(无权图,边长=1)
path[w] = u; // 记录w的前驱是u
visited[w] = TRUE; // 标记w已访问
EnQueue(Q,w); // 邻接点w入队,等待后续处理
}//if
}//for
}//while
}
核心原理文字说明
u:函数形参是起始顶点;进入 while 循环后,每次DeQueue(Q,u)会覆盖 u 的值,u
变成刚刚出队、现在要处理的顶点。d[]:存储最短距离,起点初始化为 0,其余无穷大。path[]:回溯路径用。最后想还原完整路径,可以从终点顺着 path 数组往回找到起点。visited[]:防止重复访问,保证每个顶点只会入队 1 次。考点总结(408 选择题 / 简答)
Dijkstra算法
算法步骤
也是要初始化三个数组
第一轮 循环遍历所有结点 找到还没确定最短路径 就是final为false dist最小 的顶点
另final为true
检查所有邻接自i的顶点 并且final为false 更新dist和path 这里dist要包含从起点开始的那一段长度
第二轮 继续遍历 找到final为false dist最小的顶点 将final变成true
接着就更新dist 和path
继续循环 直到所有的顶点都包含
算法总结
一共执行 轮循环(n 顶点总数)
时间复杂度 O(n2)即O(|V|2)
‼️‼️Dijkstra算法 和普利姆算法对比
Dijkstra 算出来所有单源最短路径,合在一起构成一棵【最短路径树】
最短路径树(Dijkstra) ≠ 最小生成树(Prim/Kruskal)
MST 是把所有点连起来,总代价最小;
Dijkstra 最短路径树是从根出发,到每个点各自走最近的路。
二者选出来的边经常不一样!
🔥 超级重点:Dijkstra 和 Prim 对比(408 选择题高频坑!框架很像,含义天差地别)
| 项目 | Dijkstra(最短路径) | Prim(最小生成树) | ||||
|---|---|---|---|---|---|---|
| 求解目标 | 起点到各个顶点路径总权最小 | 选出 n-1 条边,连通全部顶点,所有边权总和最小 | ||||
| 数组含义 | dist[]:起点到该点的路径总长final[]:最短路径是否确定 |
lowCost[]:该点到生成树集合的最短边权isJoin[]:顶点是否加入生成树 |
||||
| 每一轮选点依据 | 选dist 最小(起点到此点总路径最短) | 选lowCost 最小(该点连向生成树的那条边最短) | ||||
| 松弛 / 更新含义 | 更新起点到邻点的整条路径长度 | 更新邻点到生成树集合的单边长度 | ||||
| 适用场景 | 单源最短路径,不能负权 | 无向连通图最小生成树,允许 0 权 |
🔥 超级重点:结论:Dijkstra 算法不适⽤于有负权值的带权图
Floyd算法
文字比较难描述 还是看ppt吧 看会了就懂了 。。。
先理顺一遍书上的逻辑 然后在看代码和规则 就会轻松很多
代码
//……准备工作,根据图的信息初始化矩阵 A 和 path (如上图)
for (int k=0; k<n; k++){ //考虑以 vk 作为中转点
for(int i=0; i<n; i++){ //遍历整个矩阵,i为行号,j为列号
for(int j=0; j<n; j++){
if (A[i][j]>A[i][k]+A[k][j]){
A[i][j]=A[i][k]+A[k][j]; //以 vk 为中转点的路径更短
path[i][j]=k; //更新最短路径长度,记录中转点
}
}
}
}
📌 408 必背考点
顶点数n,三层循环 k,i,j,时间复杂度 ;空间(两个 n 阶矩阵:距离 A、中转 path)
适用场景:稠密图、顶点很少(n≤100)。顶点一多,爆炸,不能用。
对比 Dijkstra:Dijkstra 是单源最短路径;如果要全部点对最短路径,要跑 n 次 Dijkstra。
✅ Floyd可以处理带负权边的图
❌ 不能存在负权回路。如果图有负权环,最短路径可以无限绕环,距离无限变小,Floyd 失效。
判断负环方法:算法跑完后,检查是否存在 ,自己到自己距离小于 0,说明 i 在负环里。
path [i][j] 存的是i 到 j 最短路径上,i 之后的第一个中转点 k,不是完整序列,需要递归拆分:
i 到 j 的路径 = i → (i到k) → k → (k到j) → j
例子:path[2][1]=0:V2 到 V1,中转点 V0,路径是 V2 → V0 → V1
对比表格
| BFS算法 | Dijkstra算法 | Floyd算法 | |
|---|---|---|---|
| 无权图 | ✔ | ✔ | ✔ |
| 带权图 | ✘ | ✔ | ✔ |
| 带负权值的图 | ✘ | ✘ | ✔ |
| 带负权回路的图 | ✘ | ✘ | ✘ |
| 时间复杂度 | O(|V|²)或O(|V|+|E|) | O(|V|²) | O(|V|³) |
| 通常用于 | 求无权图的单源最短路径 | 求带权图的单源最短路径 | 求带权图中各顶点间的最短路径 |
注:也可⽤ Dijkstra 算法求所有顶点间的最短路径,重复 |V| 次即可,总的时间复杂度也是O(|V|3)
DAG描述表达式
DAG = 有向无环图,用来表示算术表达式,会把重复出现的子表达式合并、共用同一个节点,减少冗余(和普通表达式树对比)。
考点提醒(408)
掌握画的步骤应该就可以
不只是叶子结点,所有相同子表达式(不管是叶子还是运算符节点),全部只画一次。
需要手算练习
一般给你一个描述表达式 问你用有向无环图描述 需要的顶点个数至少是
拓扑排序
AOV⽹(Activity On Vertex NetWork,⽤顶点表示活动的⽹):
⽤DAG图(有向⽆环图)表示⼀个⼯程。顶点表示活动,有向边<Vi, Vj>表示活动Vi必须先于活动Vj进⾏
结点表示活动
准备厨具-》打鸡蛋-》炒——》吃 不能有环
拓扑排序:在图论中,由⼀个有向⽆环图
的顶点组成的序列,当且仅当满⾜下列条
件时,称为该图的⼀个拓扑排序:
① 每个顶点出现且只出现⼀次。
② 若顶点A在序列中排在顶点B的前⾯,则在图中不存在从顶点B到顶点A的路径
每个AOV⽹都有⼀个或多个拓扑排序序列
拓扑排序的实现:
1 从AOV⽹中选择⼀个没有前驱的顶点并输出。
2 从⽹中删除该顶点和所有以它为起点的有向边。
3 重复1和2直到当前的AOV⽹为空或当前⽹中不存在⽆前驱的顶点为⽌ (这种情况说明有回路)
代码
// 拓扑排序,返回true:无环;false:存在环
bool TopologicalSort(Graph G){
InitStack(S); // 栈:存放入度为0的顶点
// ========== 【PPT省略的部分:初始化并统计入度】 ==========
int indegree[MaxVertexNum] = {0};
//遍历所有顶点,遍历边链表,统计每个顶点入度
for(int i=0; i<G.vexnum; i++){
ArcNode *p = G.vertices[i].firstarc;
for(; p; p=p->nextarc){
int v = p->adjvex;
indegree[v]++;
}
}
// =======================================================
// 1. 把所有入度=0的顶点入栈
for(int i = 0; i < G.vexnum; i++){
if(indegree[i] == 0){
Push(S, i);
}
}
int count = 0; // 统计已经输出的顶点个数
// 2. 循环处理入度为0的顶点
while(!IsEmpty(S)){
Pop(S, i); // 弹出一个入度为0顶点i
print[count++] = i; // 输出顶点i,计数+1
// 遍历i所有出边 i → v
for(p = G.vertices[i].firstarc; p; p = p->nextarc){
int v = p->adjvex;
indegree[v]--; // 删掉边i→v,v入度减1
if(indegree[v] == 0){
Push(S, v); // 入度变成0,压入栈
}
}
}
// 3. 判断是否有环
if(count < G.vexnum)
return false; // 有环,拓扑排序失败
else
return true; // 无环,拓扑排序成功
}
二、算法步骤(代码对应逻辑)
一开始需要准备好邻接表的数据结构 背下来!!! 很多题目都是类似的
邻接表数据结构定义
#define MaxVertexNum 100 // 顶点数量上限
// 边结点(弧结点)
typedef struct ArcNode{
int adjvex; // 这条边指向的顶点下标
struct ArcNode *nextarc; // 同起点,下一条边的指针
// InfoType info; // 网(带权图)才需要,保存边权重
}ArcNode;
// 顶点结点
typedef struct VNode{
VertexType data; // 顶点本身的数据(比如顶点编号/字符)
ArcNode *firstarc; // 指向这个顶点的第一条出边
}VNode, AdjList[MaxVertexNum]; // AdjList是顶点数组类型
// 完整图结构体
typedef struct{
AdjList vertices; // 顶点数组(邻接表主体)
int vexnum, arcnum; // vexnum:当前顶点总数;arcnum:当前边(弧)总数
} Graph;
结构拆解
u → v
adjvex:目标顶点 v 在顶点数组里的下标nextarc:同一个起点 u,多条出边连成单向链表info:带权图才启用,保存边的权;普通图注释掉data:存顶点信息(如顶点编号 0、1、2)firstarc:链表头指针,指向这个顶点的第一条出边vertices:数组,存放全部顶点,数组里每个元素是 VNodevexnum:实际顶点个数;arcnum:实际弧(有向边)个数另外还需要准备
二、算法步骤(代码对应逻辑)
i,输出这个顶点,count++(记录已经输出顶点数量)i的所有邻接边(i 指向的后继顶点 v)
-1(相当于删掉边 i→v)count < 总顶点数:说明还有顶点没输出 → 图存在环,拓扑排序失败,返回 falsecount == 总顶点数:无环,拓扑排序成功,返回 true补充豆包按照邻接矩阵存储的代码
bool TopologicalSort_Matrix(GraphMatrix G){
int indegree[MaxVex] = {0};
// ①统计入度:遍历每一列
for(int j=0;j<G.n;j++){
for(int i=0;i<G.n;i++){
if(G.edge[i][j]==1)
indegree[j]++;
}
if(indegree[j]==0) Push(S,j);
}
int count=0;
while(!IsEmpty(S)){
Pop(S,u);
count++;
// 遍历u这一行,找u所有后继v
for(int v=0;v<G.n;v++){
if(G.edge[u][v]==1){ //存在u→v
indegree[v]--;
if(indegree[v]==0)
Push(S,v);
}
}
}
return count == G.n;
}
对于这里的时间复杂度的分析
邻接矩阵拓扑排序,整体时间复杂度 ,分成两大块:
G[i][j]==1 的数量,得到顶点 j 的入度。两部分相加:,大 O 只保留最高阶,最终复杂度
使用DFS实现逆拓扑排序
void DFSTraverse(Graph G){
// ① 初始化所有顶点的访问标记为FALSE
for(v=0; v<G.vexnum; v++)
visited[v] = FALSE;
// ② 从v=0开始,依次对每个未访问顶点调用DFS
for(v=0; v<G.vexnum; v++)
if(!visited[v])
DFS(G, v);
}
void DFS(Graph G, int v){
// ① 标记当前顶点v已访问
visited[v] = TRUE;
// ② 遍历v的所有邻接点w
for(w=FirstNeighbor(G,v); w>=0; w=NextNeighbor(G,v,w))
if(!visited[w]) // 如果w未访问
DFS(G, w); // 递归访问w
// ③ 【关键】所有邻接点都处理完后,才输出v
print(v);
}
// 其实就相当于后续遍历 先递归语句 最后再打印输出当前节点
关于拓扑排序的总结
1. AOV 网
2. 拓扑排序(正向拓扑)
算法步骤:
3. 逆拓扑排序
算法步骤:
4. 实现方式
两种路线:
5. 核心性质(选择题高频考点)
AOV 是顶点存活动的 DAG;拓扑挑入度 0,逆拓扑挑出度 0;有环就没有拓扑序列,结果不唯一,可用队列或 DFS 实现。
关键路径
先按照拓扑排序排好顺序
具体豆包总结
✅ 顺序:拓扑排序顺序,从前向后算
公式:
含义:这个事件最早什么时候能发生。取所有前驱路径的最大值,必须等所有前面任务全部做完。
✅ 顺序:逆拓扑排序,从后往前算
公式:
含义:事件最晚什么时候发生,不耽误总工期。取后继的最小值,不能耽误后面任务。
边代表活动
含义:活动最早什么时候开工,等于这条边起点事件的最早时间。起点事件没发生,活动不能开始。
含义:这个活动最晚什么时候开工,不耽误终点事件的最迟时间。
d:这个活动最多可以拖延多久,不影响整个工程总工期
:没有任何缓冲,不能拖,关键活动。
关键路径的性质(选择题高频坑点)
重点陷阱:只压缩其中一条关键路径上的活动,不能缩短总工期!
必须压缩所有关键路径共同包含的活动,才能缩短整体工期。
关键路径的性质(选择题高频坑点)
一句话区分:AOV 看先后,AOE 算时间。