作为数据结构的课程笔记,以便查阅。如有出错的地方,还请多多指正!
目录
图的遍历 Traversing Graph
深度优先搜索 DFS (Depth First Search)
- 从图的某一顶点
V1
出发,访问该顶点;然后依次从V1
的未被访问的邻接点出发,深度优先遍历图,直至图中所有和V1
相通的顶点都被访问到 - 若此时图中尚有顶点未被访问,则另选图中一个未被访问的顶点作起点,重复上述过程,直至图中所有顶点都被访问为止
递归实现
- 邻接表可以快速找到下一个未被访问到的顶点,因此选用邻接表作为图的存储结构
void Visit(AdjacentListDataType_t data)
{
printf("%d\r\n", data);
}
// 从顶点 v 开始 深度优先遍历一个连通分量
void DFS(pAdjacentList_t pgraph, int v, int visited[], void (*visit)(AdjacentListDataType_t data))
{
pArcNode_t edge = (pgraph->vex[v]).firstEdge;
visit((pgraph->vex[v]).data);
visited[v] = VISITED;
while (edge)
{
if (UNVISITED == visited[edge->head])
{
DFS(pgraph, edge->head, visited, visit);
}
edge = edge->nextEdge;
}
}
void DFS_traverse(pAdjacentList_t pgraph, void (*visit)(AdjacentListDataType_t data))
{
int visited[MAX_VERTEX_NUM];
for (int i = 0; i < pgraph->vexNum; ++i)
{
visited[i] = UNVISITED;
}
for (int i = 0; i < pgraph->vexNum; ++i)
{
if (UNVISITED == visited[i])
{
DFS(pgraph, i, visited, visit);
}
}
}
T ( n ) T(n) T(n)
遍历图的过程实质上是对每个顶点查找其邻接点的过程
- 邻接表:
T ( n ) = O ( n + e ) T(n)=O(n+e) T(n)=O(n+e) - 邻接矩阵:
T ( n ) = O ( n 2 ) T(n)=O(n^2) T(n)=O(n2)
广度优先搜索 BFS (Broadth First Search)
- 从图的某一顶点
V0
出发,访问该顶点后,依次访问V0
的所有未曾访问过的邻接点。 然后分别从这些邻接点出发,广度优先遍历图,直至图中所有已被访问的顶点的邻接点都被访问到 - 若此时图中尚有顶点未被访问,则另选图中一个未被访问的顶点作起点,重复上述过程,直至图中所有顶点都被访问为止
算法实现
// 从顶点 v 开始 广度优先遍历一个连通分量
void BFS(pAdjacentList_t pgraph, int v, int visited[], void (*visit)(AdjacentListDataType_t data))
{
pArcNode_t edge = (pgraph->vex[v]).firstEdge;
queue<int> q;
// 先访问再入队 (也可以在出队时进行访问)
visit((pgraph->vex[v]).data);
// 入队时一定要先更新 visited 标志,否则可能导致一些顶点被重复访问
visited[v] = VISITED;
q.push(v)
while (!q.empty())
{
int i = q.front();
q.pop();
edge = pgraph->vex[i].firstEdge;
while (edge)
{
if (UNVISITED == visited[edge->head])
{
visit((pgraph->vex[edge->head]).data);
visited[edge->head] = VISITED;
q.push(edge->head);
}
edge = edge->nextEdge;
}
}
}
void BFS_traverse(pAdjacentList_t pgraph, void (*visit)(AdjacentListDataType_t data))
{
int visited[MAX_VERTEX_NUM];
for (int i = 0; i < pgraph->vexNum; ++i)
{
visited[i] = UNVISITED;
}
for (int i = 0; i < pgraph->vexNum; ++i)
{
if (UNVISITED == visited[i])
{
BFS(pgraph, i, visited, visit);
}
}
}
图的遍历算法应用
求两个顶点间的一条简单路径
- 例如:求从顶点
b
b
b 到顶点
k
k
k 的一条简单路径: DFS 搜索:可能为
b
−
c
−
h
−
d
−
a
−
e
−
k
−
f
−
g
b-c-h-d-a-e-k-f-g
b−c−h−d−a−e−k−f−g,也可能为
b
−
a
−
d
−
h
−
c
−
e
−
k
−
f
−
g
b-a-d-h-c-e-k-f-g
b−a−d−h−c−e−k−f−g,因此直接使用 DFS 不一定能直接找到简单路径,需要增加一个堆栈
path
来存储路径
int DFS_find_simple_path(pAdjacentList_t pgraph, int a, int b,
int path_stack[], int& top, int visited[])
{
pArcNode_t edge;
bool found = false;
for (edge = (pgraph->vex[a]).firstEdge; edge && !found; edge = edge->nextEdge)
{
if (b == edge->head)
{
path_stack[top++] = edge->head; // 找到路径
found = true;
}
else if (UNVISITED == visited[edge->head])
{
visited[edge->head] = VISITED;
path_stack[top++] = edge->head;
found = DFS_find_simple_path(pgraph, edge->head, b, path_stack, top, visited);
}
}
if (!found)
{
--top; // 简单路径不经过该顶点,出栈
}
return found;
}
// 找出图中两个顶点 a b 间的一条简单路径
void Find_simple_path(pAdjacentList_t pgraph, int a, int b)
{
int* path_stack = new int[pgraph->vexNum];
int* visited = new int[pgraph->vexNum];
bool found = false;
int top = 0;
if (0 == pgraph->vexNum
|| a == b
|| a > pgraph->vexNum
|| b > pgraph->vexNum)
{
printf("No simple path can be found!\r\n");
return;
}
for (int i = 0; i < pgraph->vexNum; ++i)
{
visited[i] = UNVISITED;
}
path_stack[top++] = a;
visited[a] = VISITED;
found = DFS_find_simple_path(pgraph, a, b, path_stack, &top, visited);
if (0 == found)
{
printf("No simple path can be found!\r\n");
}
else {
printf("The simple path is:\r\n");
for (int i = 0; i < top; ++i)
{
printf("%d\r\n", pgraph->vex[path_stack[i]].data);
}
}
delete [] path_stack;
delete [] visited;
}
求无权图中两个顶点间的最短路径
- 利用双链队列进行 BFS
- 出队时,仅移动队头指针,而不删除队头结点
- 入队时,令新队尾结点的
prior
域指向队头指针所指结点
更简单的方法应该是给每个节点都增加一个数据域,记录 BFS 访问时它们的父结点序号,这样在 BFS 到目标节点后,沿着父结点序号一路逆推过去就行了
生成树, 生成森林
生成树
- 极小连通子图。它包含图中全部 n n n 个顶点,且只含 n − 1 n-1 n−1 条边。因此,在生成树中再加一条边必然形成回路,且生成树中任意两个顶点间的路径是唯一的
生成森林
- 非连通图每个连通分量的生成树
- 无向图中,生成森林中树的数量 = 连通分量数
- 有向图中,生成森林中树的数量可能少于强连通分量 (一个强连通分量可得到对应的生成树,但非强连通分量也可能只需要一棵生成树与之对应)
最小生成树
Minimum Cost Spaning Tree (MST)
- 要在
n
n
n 个城市间建立通信联络网,顶点表示城市,权表示城市间建立通信线路所需花费代价。希望找到一棵生成树,它的每条边上的权值之和(即建立该通信网所需花费的总代价)最小———最小代价生成树
注意到,MST 问题具有最优子结构性质
Prim 算法 (普里姆算法)
- 设
N
=
(
V
,
{
E
}
)
N=(V,\{E\})
N=(V,{E}) 是连通网,
G
=
(
U
,
{
T
E
}
)
G=(U,\{TE\})
G=(U,{TE})是
N
N
N 上最小生成树
- 初始令 U = { u 0 } , ( u 0 ∈ V ) , T E = ϕ U=\{u_0\},(u_0\in V), TE=\phi U={u0},(u0∈V),TE=ϕ
- 在所有 u ∈ U , v ∈ V − U u\in U,v\in V-U u∈U,v∈V−U 的边 ( u , v ) ∈ E (u,v)\in E (u,v)∈E 中,找一条代价最小的边 ( u 0 , v 0 ) (u_0,v_0) (u0,v0) 并入集合 T E TE TE,同时 v 0 v_0 v0 并入 U U U
- 重复上述操作直至
U
=
V
U=V
U=V (共重复
n
−
1
n-1
n−1 次)
贪心算法,依次得到包含 u 0 u_0 u0 的一个顶点、两个顶点 … n n n 个顶点的最小代价生成树
算法实现
const int MAXV = 1000; // 最大顶点数
const int INF = 0x3fffffff; // 设 INF 为一个很大的数
邻接矩阵
- O ( n 2 ) O(n^2) O(n2)
int n, G[MAXV][MAXV]; // n 为顶点数, MAXV 为最大顶点数
int d[MAXV]; // 顶点与集合 S 的最短距离
bool vis[MAXV] = {false}; // 标记数组,vis[i] == true 表示已访问。初值均为 false
int prim() { // 函数返回最小生成树的边权之和
fill(d, d + MAXV, INF); // fill 函数将整个 d 数组赋为 INF
d[0] = 0; // 默认 0 号为初始点
int ans = 0; // 存放最小生成树的边权之和
for(int i = 0; i < n; i++) {
int u = -1, MIN = INF; // u 使 d[u] 最小, MIN 存放该最小的 d[u]
for(int j = 0; j < n; j++) { // 找到未访问的顶点中 d[] 最小的
if (vis[j] == false && d[j] < MIN) {
u = j;
MIN = d[j];
}
}
// 找不到小于 INF 的 d[u], 则剩下的顶点和集合 s 不连通
if(u == -1)
return -1;
vis[u] = true;
ans += d[u]; // 将与集合 s 距离最小的边加入最小生成树
for(int v = 0; v < n; v++) {
if(vis[v] == false && G[u][v] != INF && G[u][v] < d[v]) {
d[v] = G[u][v];
}
}
}
return ans;
}
邻接表
struct Node{
int v, dis; // v 为边的目标顶点, dis 为边权
};
vector<Node> Adj[MAXV]; // 邻接表;Adj[u] 存放从顶点 u 出发可以到达的所有顶点
int n; // n 为顶点数
int d[MAXV]; // 顶点与集合 s 的最短距离
bool vis[MAXV] = {false}; // 标记数组,vis[i] == true 表示已访问。初值均为 false
// O(n^2)
int prim() { // 函数返回最小生成树的边权之和
fill(d, d + MAXV, INF);
d[0] = 0; // 默认 0 号为初始点
int ans = 0; // 存放最小生成树的边权之和
for(int i = 0; i < n; i++) {
int u = -1, MIN = INF;
for(int j = 0; j < n; j++) {
if(vis[j] == false && d[j] < MIN) {
u = j;
MIN = d[j];
}
}
if(u == -1)
return -1;
vis[u] = true;
ans += d[u]; // 将与集合 s 距离最小的边加入最小生成树
// 只有下面这个 for 与邻接矩阵的写法不同
for(int j = 0; j < Adj[u].size(); j++) {
int v = Adj[u][j].v; // 通过邻接表直接获得 u 能到达的顶点 v
if(vis[v] == false && Adj[u][j].dis < d[v]) {
d[v] = Adj[u][j].dis;
}
}
}
return ans;
}
- 通过堆优化可以进一步将时间复杂度降为
O
(
n
log
n
+
e
)
O(n\log n+e)
O(nlogn+e)
- 不太明白这个时间复杂度
O
(
n
log
n
+
e
)
O(n\log n+e)
O(nlogn+e) 是怎么得出来的;似乎是忽略了最小堆
push
的时间
- 不太明白这个时间复杂度
O
(
n
log
n
+
e
)
O(n\log n+e)
O(nlogn+e) 是怎么得出来的;似乎是忽略了最小堆
struct Node {
int v, dis; // v 为边的目标顶点, dis 为边权
friend bool operator>(const Node& lhs, const Node& rhs)
{
return lhs.dis > rhs.dis;
}
};
int prim() { // 函数返回最小生成树的边权之和
priority_queue<Node, vector<Node>, greater<Node>> heap; // 最小堆
heap.push({ 0, 0 });
fill(d, d + MAXV, INF);
d[0] = 0; // 默认 0 号为初始点
int ans = 0; // 存放最小生成树的边权之和
int num = 0; // 添加进生成树的顶点数
while(!heap.empty() && num < n) {
Node edge = heap.top();
int u = edge.v;
heap.pop();
if (vis[edge.v])
{
continue;
}
vis[u] = true;
ans += edge.dis; // 将与集合 s 距离最小的边加入最小生成树
// 只有下面这个 for 与邻接矩阵的写法不同
for (int j = 0; j < Adj[u].size(); j++) {
int v = Adj[u][j].v; // 通过邻接表直接获得 u 能到达的顶点 v
if (vis[v] == false && Adj[u][j].dis < d[v]) {
d[v] = Adj[u][j].dis;
heap.push({ v, d[v] });
}
}
}
return ans;
}
算法评价
- 邻接矩阵 (适合稠密图):
T ( n ) = O ( n 2 ) T(n)=O(n^2) T(n)=O(n2) - 邻接表 (适合稀疏图):
T ( n ) = O ( n log n + e ) T(n)=O(n\log n+e) T(n)=O(nlogn+e)
Kruskal 算法
- 设连通网
N
=
(
V
,
E
)
N=(V,E)
N=(V,E),令最小生成树
- 初始状态为只有 n n n 个顶点而无边的非连通图 T = ( V , ϕ ) T=(V,\phi) T=(V,ϕ),每个顶点自成一个连通分量
- 在 E E E 中选取代价最小的边,若该边依附的顶点落在 T T T 中不同的连通分量上,则将此边加入到 T T T 中;否则,舍去此边,选取下一条代价最小的边
- 依此类推,直至 T T T 中所有顶点都在同一连通分量上为止
贪心算法;每一步都添加一条边都使连通块个数减一,并且由于选择的是最短的边,因此每一步得到的都是使连通块数个数减一的所有方案中的最优解;这样重复 n − 1 n-1 n−1 次 (添加 n − 1 n-1 n−1 条边) 之后,图中就只有一个连通块,也就得到了一棵最小生成树
伪代码
int kruskal() {
令最小生成树的边权之和为 ans, 最小生成树的当前边数 Num_Edge;
将所有边按边权从小到大排序;
for (从小到大枚举所有边) {
if (当前测试边的两个端点在不同的连通块中) {
将该测试边加入最小生成树中;
ans += 测试边的边权;
最小生成树的当前边数 Num_Edge 加 1;
当边数 Num_Edge 等于顶点数减 1 时结束循环;
}
}
return ans;
}
- 其中,判断两个端点是否在同一连通块中可以通过并查集来实现
算法实现
struct edge {
int u, v; // 边的两个端点编号, 用于判定它们是否属于同一个连通块
int cost; // 边权
}E[MAXE]; // 最多有 MAXE 条边
// 并查集
int father[N];
int findFather(int v) {
if (v == father[v])
return v; // 找到根结点
else {
int F = findFather(father[v]);
father[v] = F;
return F;
}
}
// kruskal 函数返回最小生成树的边权之和
// 参数 n 为顶点个数, m 为图的边数
int kruskal(int n, int m) {
// ans 为所求边权之和, Num_Edge 为当前生成树的边数
int ans = 0, Num_Edge = 0;
for(int i = 1; 1 <= n; i++) ( // 假设顶点范围是 [1,n]
father[i] = i; // 并查集初始化 -> 初始时所有端点都属于不同的连通块
}
// 所有边按边权从小到大排序
sort(E, E + m, [](const &edge lhs, const &edge rhs) -> bool {
return lhs.cost < rhs.cost;
});
for(int i = 0; i < m; i++) { // 枚举所有边
int faU = findFather(E[i].u); // 查询测试边两个端点所在集合的根结点
int faV = findFather(E[i].v);
if (faU != faV) { // 如果不在一个集合中
father[faU] = faV; // 合并集合(即把测试边加入最小生成树中)
ans += E[i].cost; // 边权之和增加测试边的边权
Num_Edge++; // 当前生成树的边数加1
if(Num_Edge == n - 1)
break; // 边数等于顶点数减 1 时结束算法
}
}
if(Num_Edge != n - 1)
return -1; // 无法连通时返回-1
else
return ans; // 返回最小生成树的边权之和
}
算法评价
- 可以看到,kruskal 算法的时间复杂度主要来源于对边进行排序, 因此其时间复杂度是
O ( e log e ) O(e\log e) O(eloge)显然 kruskal 适合顶点数较多、边数较少的情况, 这和 prim 算法恰好相反。因此,如果是稠密图(边多), 则用 prim 算法;如果是稀疏图(边少), 则用 kruskal 算法