基本知识点
图可说是所有数据结构里面知识点最丰富的一个, 自己笨的知识点如下:
-
阶(oRDER), 度: 出度(out-Degree), 入度(in-Degree)
-
树(Tree), 森林(Forest), 环(Loop)
-
有向图(Directed Graph), 无向图(Undirected Graph), 完全有向图, 完全无向图
-
连通图(Connected Graph), 连通分量(Connnected Component)
- 存储和表达式: 邻接矩阵(Adjacency Matrix), 邻接链表(Adjacency List)
围绕图的算法也是五花八门
-
图的遍历: 深度优先, 广度优先
-
环的检测: 有向图, 无向图
-
拓扑排序
-
最短路劲算法: Dijkstra, Bellman-Ford, Floyd Warshall
-
连通性相关算法: Kosaraju, Tarjan, 求解孤岛的数量, 判断是否为树
- 图的着色, 旅行商问题等
以上的知识点知识图轮里的冰山一角, 对于算法面试而言, 完全不需要对每个知识点都一一掌握, 而应该有的放矢的准备
必会的知识点
-
以下的知识点必须充分掌握并反复练习
-
图的存储和表达式: 邻接矩阵(Adjacency Matrix), 邻接链表(Adjacency List)
-
图的遍历: 深度优先, 广度优先
-
二部图的检测(Bipartite), 数的检测, 环的检测, 有向图, 无向图
-
拓扑排序
-
联合-查找算法(Union-Find)
- 最短路径Dijkstra, BellMan-Ford
其中, 环的检测, 二部图的检测, 树的检测以及拓扑都是基于图的遍历, 尤其是深度优先方式的遍历, 而遍历可以在邻接矩阵或者邻接链表上进行, 所以掌握好图的遍历是重中之重, 因为它是所有其他图论算法的基础
至于对端路劲算法, 能区分它们的不同特点, 知道在什么情况下用哪种算法就很好了, 对于有充足时间准备的面试者, 能熟练掌握他们的写法当然是很好的
我们来来看看数据结构中的图到底是什么
1. 图的定义
图是由一些点(vertex)和这些点之间的连线(edge)所组成的, 其中, 点通常称为顶点(vertex), 而点到点之间的连线通常称之为边或者弧(edge), 通常记为G = (V,E)
2. 图的分类
图通常分为有向图和无向图, 而其表示方式分为邻接矩阵和邻接链表, 具体表示如下图.
对于无向图,其所有的边都不区分方向。G=(V,E)。其中,
-
V={1,2,3,4,5}。V表示有”1,2,3,4,5”几个顶点组成的集合。
- E={(1,2),(1,5),(2,1),(2,5),(2,4),(2,3),(3,2),(3,4),(4,3),(4,2),(4,5),(5,1),(5,2),(5,4)}。E就是表示所有边组成的集合,如(1,2)表示由顶点1到顶点2连接成的边。
对于有向图,其所有的边都是有方向的。G=(V,E)。其中,
-
V={1,2,3,4,5}。V表示有”1,2,3,4,5”几个顶点组成的集合。
- E={<1,2>,<2,5><5,4>,<4,2><3,5>,<3,6>,<6,6>}。E就是表示所有边组成的集合,如<1,2>表示由顶点1到顶点2连接成的边。注意有向图边和无向图边表示方法的不同,有向图的边是矢量,而无向图只是普通的括号。
针对邻接矩阵和邻接链表两种不同的表示方式,有如下优缺点:
-
邻接矩阵由于没有相连的边也占据空间,相对于邻接链表,存在空间浪费的问题;
- 但是在查找的时候,邻接链表会比较耗时,对于邻接矩阵来说,它的查找复杂度就是O(1)。
用邻接表表示图的代码
#define MAX 100
typedef struct ENode //邻接表中表对应的链表的顶点
{
int ivex; //该边所指向的顶点的位置
int weight; //该边的权值
struct ENode *next_edge; //指向下一条边的指针
}ENode,*PENode;
typedef struct VNode //邻接表中表的顶点
{
char data; //顶点的数据
struct VNode *first_edge; //指向第一条依附该顶点的边
}VNode;
typedef struct LGraph //邻接表
{
int vexnum; //图的顶点数
int edgenum; //图的边数
VNode vexs[MAX];
}LGraph;
3. 度, 权, 连通图等概念
对于无向图来说,它的顶点的度就是指关联于该顶点的边的数目;而对于有向图来说,分为入度和出度,所谓入度就是进入该顶点边的数目,出度就是离开这个顶点边的数目,有向图的度就是入度加出度。
图还被分为有权图和无权图,所谓有权图就是每条边都具有一定的权重,通常就是边上的那个数字;而无权图就是每条边没有权重,也可以理解为权重为。如下图所示即为有权图,(A,B)的权就是13。
如果一个无向图中每个顶点到所有其他顶点都是可达的,则称该图是连通的;如果一个有向图中任意两个顶点互相可达,则该有向图是强连通的。
如图(b)中有3个连通分量:{1,2,5},{3,6},{4}。若一个无向图只有一个连通分量,则该无向图连通。
而图(a)中有3个强连通分量:{1,2,4,5},{3},{6}。{1,2,4,5}中所有顶点对互相可达。而顶点2与6不能相互可达,所以不能构成一个强连通分量。
4. 深度优先搜索(Depth First Search DFS)
图的深度优先算法有点类似于树的前序遍历,首先图中的顶点均未被访问,确定某一顶点,从该顶点出发,依次访问未被访问的邻接点,直到某个邻接点没有未被访问邻接点时,则回溯父节点(此处我们将先被访问的节点当做后被访问节点的父节点,例如对于节点A、B,访问顺序是A ->B,则称A为B的父节点),找到父节点未被访问的子节点;如此类推,直到所有的顶点都被访问到。
注意,深度优先的存储方式一般是以栈的方式存储。
- 无向图的深度优先搜索
- 有向图的深度优先搜索
- 深度优先搜索代码
static void DFS(LGraph G, int i,int *visited)
{
Enode *node;E
printf(“%c”,G.vexs[i].data);
node = G.vexs[i].first_edge;
while(node != NULL)
{
if(!visited[node->ivex])
DFS(G, node->ivex, visited); //递归调用DFS
node = node->next_edge;
}
}
5. 广度优先搜索
从图中的某个顶点出发,访问它所有的未被访问邻接点,然后分别从这些邻接点出发访问它的邻接点。说白了就是一层一层的访问,“浅尝辄止”!
注意,广度优先搜索的存储方式一般是以队列的方式存储。
- 无向图的广度优先搜索
- 有向图的广度优先搜索
- 广度优先所有代码
void BFS(LGraph G)
{
int head = 0;
int rear = 0;
int queue[MAX]; //辅助队列
int visited[MAX]; //顶点访问标记
eNode *node;
for(int i = 0; i < G.vexnum; i++)
visited[i] = 0; //初始化所有顶点,标记为未访问
printf(“BFS:”);
for(int i = 0; i < G.vexnum; i++)
{
if(!visited[i])
{
visited[i] = 1;
printf(“%c”,G.vexs[i].data);
queue[rear++] = i; //入队
}
while(head != rear)
{
int j = queue[head++]; //出队
node = G.vexs[j].first_edge;
while(node != NULL)
{
int k = node->ivex;
if(!visited[k])
{
visited[k] = 1;
printf(“%c”,G.vesx[k].data);
queue[rear++] = k;
}
node = node->next_edge;
}
}
}
printf(“\n”);
}
6.拓扑排序
拓扑排序(Topological Order)是指讲一个有向无环图(Directed Acyclic Graph,DAG)进行排序而得到一个有序的线性序列。
举个例子,例如我们早上起床的穿衣顺序,如下图所示。穿衣的顺序也是有个优先级的,有些衣服就必须优先穿上,例如领带依赖于衬衣,所以领带最终排在衬衣之后;对图a中的元素进行合理的排序,就得到了图b的次序图。注意,该次序图不是唯一的。
int topological_sort(LGraph G)
{
int num = G.vexnum;
ENode *node;
int head = 0; //辅助队列的头
int rear = 0; // 辅助队列的尾
int *ins = (int *)malloc(num * sizeof(int)); //入度数组
char *tops = (char *)malloc(num * sizeof(char)); //拓扑排序结果数组,记录每个节点排序后的序号
int *queue = (int *)malloc(num * sizeof(int)); //辅助队列
assert(ins != NULL && tops != NULL && queue != NULL)
memset(ins, 0, num * sizeof(int));
memset(tops, 0, num * sizeof(char));
memset(queue, 0, num * sizeof(int));
for(int i = 0; i < num; i ++) //统计每个顶点的入度数
{
node = G.vexs[i].first_edge;
while(node != NULL)
{
ins[node->ivex]++;
node = node->next_edge;
}
}
for(int i = 0; i < num; i++) //将所有入度为0的顶点装入队列
if(ins[i] == 0)
queue[rear++] = i;
while(head != rear) //队列非空
{
int j = queue[head++]; //出队列,j为顶点的序号
tops[index++] = G.vexs[j].data; //将该顶点添加到tops中,tops是排序结果
node = G.vexs[j].first_edge; //获取以该顶点为起点的出边队列
while(node != NULL)
{
ins[node->ivex]--; //将节点node关联的节点的入度减1
if(ins[node->ivex] == 0) //若节点的入度为0,则将其添加到队列中
queue[rear++] = node->ivex;
node = node->next_edge;
}
}
if(index != G.vexnum)
{
printf(“Graph has a cycle!\n”);
free(queue);
free(ins);
free(tops);
return 1; //1表示失败,该有向图是有环的
}
printf(“== TopSort: ”); //打印拓扑排序结果
for(int i = 0; i < num; i++)
printf(“%c”,top[i]);
printf(“\n”);
free(queue);
free(ins);
free(tops);
return 0;
}
7. 最小生成树
所谓最小生成树就是将图中的顶点全部连接起来,此时这个边的权重最小,并且连接起来的是一个无环的树。很容易知道,若此时的顶点是n,则边的数量为n-1。所以在一个图中找最小生成树就是找最小权值的边,让这些边连成一棵树。常用的算法有Prim算法和Kruskal算法。
7. 1Prim算法
该算法就是每次迭代选择权值最小的边对应的点,加入到最小生成树中。具体实现如下所示。
第一步:选取顶点A,此时U={A},V-U={B,C,D,E,F,G}。
第二步:选取与A点连接的权值最小的边,此时就会选择到B,U={A,B},V-U={C,D,E,F,G}。
以上面的步骤类推,得到如上图所示的结果,此时U={A,B,C,D,E,F},V-U={G}。注意到C是此次加入的点,而G没有加入,此时G点的边应该如何选择?
最终,得到如图所示的最小生成树,此时U={A,B,C,D,E,F,G},V-U={}。
#define INF (~(0x1<<31)) //最大值(即0X7FFFFFFF)
//返回ch在邻接表中的位置
static int get_position(LGraph G, char ch)
{
for(int i = 0; i < G.vexnum; i++)
if(G.vexnum[i].data == ch)
return i;
return -1;
}
//获取G中边的权值;若start到end不连通,则返回无穷大
int getWeight(LGraph G, int start, int end)
{
ENode *node;
if(start == end)
return 0;
node = G.vexs[start].first_edge;
while(node != NULL)
{
if(end == node->ivex)
return node->weight;
node = node->next_edge;
}
return INF;
}
void Prim(LGraph G,int start) //从图中的第start个元素开始,生成最小树
{
int index = 0; //prim最小树的索引,即prims数组的索引
char prims[MAX]; //prim最小树的结果数组
int wights[MAX]; //顶点间边的权重
//prim最小生成树中第一个数,即图中的第start个数
prims[index++] = G.vexs[start].data;
for(int i = 0; i < G.vexnum; i++) //初始化顶点的权值数组
weights[i] = getWeight(G, start, i); //将每个顶点的权值初始化为“第start个顶点”到“该顶点”的权值
for(int i = 0; i < G.vexnum; i++)
{
if(start == i) //由于从start开始,因此不需要再对第start个顶点进行处理
continue;
int j = 0;
int k = 0;
int min = INF;
//在未被加入到最小生成树的顶点中,找出权值最小的顶点
while(j < G.vexnum)
{
//若weights[j]=0,则说明该节点已经加入了最小生成树
if(weights[j] != 0 && weights[j] < min)
{
min = weights[j];
k = j;
}
j++;
}
//经过上面的处理后,在未被加入到最小生成树的顶点中,权值最小的顶点是第k个顶点。
//将第k个顶点加入最小生成树的结果数组中
prims[index++] = G.cexs[k].data;
//将第k个顶点的权值标记为0,表示该顶点已经加入了最小生成树
weights[k] = 0;
//当第k个顶点被加入到最小生成树的结果数组后,更新其他顶点的权值
for(int j = 0; j < G.vexnum; j++)
{
//获取第k个顶点到第j个顶点的权值
int temp = getWeight(G, k, j);
//当第j个节点没有被处理,并且需要更新的时候才会更新
if(weights[j] != 0 && temp < weights[j])
weights[j] = temp;
}
}
//计算最小生成树的权值
int sum = 0;
for(int i = 0; i < index; i++)
{
min = INF;
//获取prims[i]在G中的位置
int n = get_position(G, prims[i]);
//在vexs[0...i]中,找出到j的权值最小的顶点。
for(int j = 0; j < i; j++)
{
int m = get_position(G,prims[j]);
int temp = getWeight(G, m, n);
if(temp < min)
min = temp;
}
sum += min;
}
//打印最小生成树
printf(“Prim(%c) = %d : ”, G.vexs[start].data, sum);
for(int i = 0; i < index; i++)
printf(“%c ”,prim[i]);
printf(“\n”);
}
7.2 Kruskal算法
该算法的核心就是对权值进行排序,然后从最小的权值开始,不断增大权值,如何该权值的所在边的两个顶点没有存在的路径连在一起,则加入这条边,否则,则舍弃这条边,知道所有的点都在这颗树中。
如下所示的一个图,我们从中找出最小生成树。
对于左边所示的图,对各个边的权值排序之后,我们最先找到权值最小的边,即AD。然后我们发现还有一个CE,于是CE也会被标记起来。
对于左边所示的图,对各个边的权值排序之后,我们最先找到权值最小的边,即AD。然后我们发现还有一个CE,于是CE也会被标记起来。
typedef struct edata //边的结构体
{
char start; //边的起点
char end; //边的终点
int weight; //边的权重
}EData;
EData *get_edges(LGraph G)
{
int index = 0;
ENode *node;
EData *edges;
edges = (EData *)malloc(G.edgnum * sizeof(EData));
for(int i = 0; i < G.vexnum; i++)
{
node = G.vexs[i].first_edge;
while(node != NULL)
{
if(node->ivex > i)
{
edges[index].start = G.vexs[i].data;
edges[index].end = G.vexs[node->ivex].data;
edges[index].weight = node->weight;
index++;
}
node = node->next_edge;
}
}
return edges;
}
void Kruskal(LGraph G)
{
int index = 0; //rets数组的索引
int vends[MAX] = {0}; //用于保存“已有最小生成树”中每个顶点在该最小树中的终点
EData rets[MAX]; //结果数组,保存kruskal最小生成树的边
EData *edges; //图对应的所有边
edges = get_edges(G); //获取图中所有的边
Sorted_edges(edges, G.edgenum); //对边按照权值进行排序
for(int i = 0; i < G.edgenum; i++)
{
int numOfStart = get_position(G, edges[i].start); //获取第i条边的起点的序号
int numOfEnd = get_position(G, edges[i].end); //获取第i条边的终点的序号
int m = get_end(vends, numOfStart); //获取numOfStart在“已有的最小生成树”中的终点
int n = get_end(vends, numOfEnd); //获取numOfEnd在“已有的最小生成树”中的终点
//如果m!=n,表示边i与已经添加到最小生成树中的顶点没有形成环路
if(m != n)
{
vends[m] = n; //设置m在已有的最小生成树中的终点为n
rets[index++] = edges[i]; //保存结果
}
}
free(edges);
//统计并打印最小生成树的信息
int length = 0;
for(int i = 0; i < index; i++)
length += rets[i].weight;
printf(“Kruskal = %d : ”,length);
for(int i = 0; i < index; i++)
printf(“(%c,%c)”, rets[i].start, rets[i].end);
printf(“\n”);
}
例题分析
- 判断二分图
给定一个无向图graph,当这个图为二分图时返回true。
如果我们能将一个图的节点集合分割成两个独立的子集A和B,并使图中的每一条边的两个节点一个来自A集合,一个来自B集合,我们就将这个图称为二分图。
graph将会以邻接表方式给出,graph[i]表示图中与节点i相连的所有节点。每个节点都是一个在0到graph.length-1之间的整数。这图中没有自环和平行边:graph[i] 中不存在i,并且graph[i]中没有重复的值。
示例 1:
输入: [[1,3], [0,2], [1,3], [0,2]]
输出: true
解释:
无向图如下:
0----1
| |
| |
3----2
我们可以将节点分成两组: {0, 2} 和 {1, 3}。
示例 2:
输入: [[1,2,3], [0,2], [0,1,3], [0,2]]
输出: false
解释:
无向图如下:
0----1
| \ |
| \ |
3----2
我们不能将节点分割成两个独立的子集。
好了, 自己研究了半天, 题都没有研究明白,我决定放弃了, 要是哪个大佬知道, 快来教教我吧, 我们今天把树是个什么东西就好了!
graph 的长度范围为 [1, 100]。graph[i] 中的元素的范围为 [0, graph.length - 1]。graph[i] 不会包含 i 或者有重复的值。图是无向的: 如果j 在 graph[i]里边, 那么 i 也会在 graph[j]里边。
注意: