【算法笔记】- 图整理

《算法笔记》】-- 图算法

图的存储

邻接矩阵一般只适用于顶点数目不太大的题目(一般不超过1000)

邻接表 (顶点数大于1000的题目)

  • vector实现

开一个vector数组Adj[N] ,其中N为顶点个数

  • 如果邻接表只存储每条边的终点编号,而不存储边权,则vector中的元素类型可以直接定义为int
  • 如果需要存储边权,则把替换类型替换为结构体Node

注:结构体的初始化可以定义构造函数

strcut Node{
	int v, w;
	Node(int _v, int _w) : v(-v), w(_w) {}
}

//此时可以不定义临时变量即可实现加边操作
Adj[i].push_back(Node(3, 4));

图的遍历(DFS & BFS)
| 伪代码
DFS(u) { //访问顶点u
	vis[u] = true;  //设置u已被访问
	for(从u出发能到达的所有顶点v)   //枚举
		if(vis[v] = false)
			DFS(v);
}
DFSTrave(G) {
	for(G的所有顶点u)   //枚举
		if (vis[u] == false
			DFS(u);    //访问u所在的连通块
}
BFS(u) { //遍历u所在的连通块
	queue q;   //定义队列
	将u入队列
	inq[u] = true;  //设置u已入队列
	while(q非空) {
		取出q的队首元素u进行访问;
		for(从u出发能到达的所有顶点v)
			if(inq[v] == false){  //若未曾加入队列
				将v入队列
				inq[v] = true; //标记v加入队列
			}
	}//while
}

BFSTrave() { //遍历图
	for(G的所有顶点u) 
		if(inq[u] == false)
			BFS(u);  //遍历u所在的连通块
}
| DFS实现
  • 邻接矩阵 版本
int n, G[maxn][maxn];  //n为顶点数,maxn为最大顶点数
bool vis[maxn] = {false};

void DFS(int u, int depth) {
    
    vis[u] = true; //设置u已被访问
    
    //如果需要对u进行一些操作,在此处进行
    
    for(int v = 0; v < n; v++) //枚举所有结点
        if(vis[v] == false && G[u][v] != INF) //若未曾访问,且u可到达v
            DFS(v, depth + 1);  //访问i,深度+1

}

void DFSTrave() { //遍历图G
    for(int u = 0; u < n; u++)
        if(vis[u] == false)
            DFS(u, 1);      //初始为第一层
}
  • 邻接表 版本
vector<int> Adj[maxn];  //图G的邻接表
int n; //n为顶点数
bool vis[maxn] = {false};

void DFS(int u, int depth) {
    
    vis[u] = true; //设置u已被访问
    
    //如果需要对u进行一些操作,在此处进行
    
    for(int i = 0; i < Adj[u].size(); i++) { //枚举所有结点
        int v = Adj[u][i];  //vector可按下标访问
        if(vis[v] == false)  //若未曾访问,且u可到达v
            DFS(v, depth + 1);  //访问i,深度+1
	}//for
}

void DFSTrave() { //遍历图G
    for(int u = 0; u < n; u++)
        if(vis[u] == false)
            DFS(u, 1);      //初始为第一层
}

| BFS实现
  • 邻接矩阵 版本
int n, G[maxn][maxn]; //n为顶点数
bool inq[maxn] = {false}; //标记结点是否入队列

void BFS(int u) {
    queue<int> q;
    q.push(u);
    inq[u] = true;  //标记已入队列
    while(!q.empty()) {
        int u = q.front();
        q.pop();
        for (int v = 0; v < n; v++) {
            if(inq[v] == false && G[u][v] != INF) {//未曾入队列,且可达
                q.push(v);
                inq[v] = true; //标记v已经入队列
            }
        }
    }//while
}

void BFSTrave() { //遍历图
	for(int u = 0; u < n; u++) 
		if(inq[u] == false)
			BFS(u);  //遍历u所在的连通块
}

  • 邻接表 版本
vector<int> Adj[maxn];  //图G的邻接表
int n; //n为顶点数
bool inq[maxn] = {false};

void BFS(int u) {
    queue<int> q;
    q.push(u);
    inq[u] = true;  //标记已入队列
    while(!q.empty()) {
        int u = q.front();
        q.pop();
        for (int i = 0; i < Adj[u].size(); i++) {
            int v = Adj[u][i];  //vector可按下标访问
            if(inq[v] == false) {//未曾入队列
                q.push(v);
                inq[v] = true; //标记v已经入队列
            }
        }
    }//while
}

void BFSTrave() { //遍历图
	for(int u = 0; u < n; u++) 
		if(inq[u] == false)
			BFS(u);  //遍历u所在的连通块
}

注:当题目要求输出结点的层号时,只需把vector<>中存储的元素换成结构体即可,结构体中包括结点编号层号信息。遍历的同时更新层号信息,子节点是父节点结点的层号+1。结构体如下:

struct node {
	int v;
	int layer;
};

最短路径
| Dijkstra 算法

练习试题:PAT. A1003 || PAT. A1030 || PAT. A1018 || PAT. A1072 || PAT. A1087 ||

  • 伪代码
//G为图,一般设为全局变量,数组d为源点到达各个点的最短路径长度,s为起点
Dijkstra(G, d[], s) {
    初始化;
    for(循环n次) {
        u = 使d[u]最小且还未被访问的顶点的标号;  //暴力搜索 or 堆结构
        标记u已被访问;
        for(从u出发能到达的所有顶点v) {
            if (v未被访问 && 以u为中介点 使 s到顶点v的最短距离d[v]更优) {
                优化d[v];
	            //可以在此处保存路径 把u保存为v的前驱即可
	            pre[v] = u;
            }
        }
    }//for
}//Dijkstra


实现差异: 主要区别主要在于如何枚举从u出发到达的顶点v上;邻接矩阵需要枚举所有结点查看顶点v能否到达u,而邻接表则可以直接得到这些顶点v

注: 若题目所给为无向图,把它转化为两条有向边即可!

const int maxn = 1010;
const int INF = 0x7fffffff;

int n; //结点数
int d[maxn]; //起点到各顶点的最短距离
bool vis[maxn] = {false}; //标记数组,标记是否已经访问
int pre[maxn]; //保存结点前驱,用于获取最短路径
  • 邻接矩阵版本
int G[maxn][maxn]; //顶点数,图

//邻接矩阵版本
void Dijkstra(int s) {
    fill(d, d + maxn, INF); //初始为不可达(慎用memset)
    d[s] = 0;
    
    for(int i = 0; i < n; i++) { //循环n次(第一次找到的肯定是起点本身,正好完成初始化)

	     //找到未访问结点中d[]最小的 
        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)  //找不到小于INF的d[u],说明剩下的顶点和起点s不连通
            return;

        vis[u] = true; //标记已访问
        for(int v = 0; v < n; v++) { //更新
            //如果v未访问 && u能到达v && 以u为中介点可以是d[v]更优
            if(vis[v] == false && G[u][v] != INF && d[u] + G[u][v] < d[v]) {
                d[v] = G[u][v] + d[u];
	            pre[v] = u;
            }
        }

    }//for - i
}//Dijkstra

//时间复杂度:两层循环 ,O(n^2)


  • 邻接表版本
struct node{
    int v, dis; //v为边的目标结点,dis为边权
};
vector<node> Adj[maxn]; //邻接表; Adj[u]保存从u出发能到达的所有顶点(结构体中还保存了其间的边权)

//邻接表版本
void Dijkstra(int s) {
    fill(d, d + maxn, INF);
    d[s] = 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;
        
        vis[u] = true;
        for(int j = 0; j < Adj[u].size(); j++){ //注意vector<>保存的是结构体
            int v = Adj[u][j].v; 
            if(vis[v] == false && Adj[u][j].dis + d[u] < d[v]){
                d[v] = d[u] + Adj[u][j].dis;
	            pre[v] = u;
            }
        }
        
    }//for - i
}//Dijkstr

//时间复杂度:O(V^2 + E) 【双重循环遍历结点;每条边都遍历且只遍历了一遍】

  • 路径保存
    新增一个数组pre[]pre[v]表示最短路径上v的前驱;
    每次在更新优化时 把u保存为v的前驱即可(见伪代码);
    Dijkstra()结束后,从目标点DFS()回溯即可得到最短路径;
void DFS(int s, int v) {
    if(v == s){ //如果已经到达起点,则输出并返回
        printf("%d\n", s);
        return;
    }
    DFS(s, pre[v]);  //回溯
    printf("%d\n", v); //等返回后在输出
}
  • Dijkstra 优化
    最外层的循环O(V)是无法避免的,但是寻找最小距离d[u]可以用堆结构优化,是内部复杂度降到O(logV),整体复杂度可以到O(VlogV + E)
    【堆结构可以直接用STLpriority_queue实现】

  • 题目考法
    很多时候最短路径不止一条,就需要题目所给的其他条件选择其中一条;一般有一下三种考法:
    1、给每条边再增加一个边权(比如花费),然后要求最短路径有多条时,选择花费之和最小的;
    2、给每个点增加一个点权,有多条最短路径时,选择点权之和最大(最小)的;
    3、直接问有多少条最短路径;
    这三种出法都只需增加一个数组,存放新增的边权或点权或最短路径条数然后在Dijkstra()中修改 更新d[v] 的那一步操作即可;其他无需改变;

//考法一:边增加花费
int cost[maxn][maxn]; //存储边的额外信息
int c[maxn];   //存储到每个点最短路径的累计花费
for(int v = 0; v < n; v++) {
    if(vis[v] == false && G[u][v] != INF) {
        if(d[u] + G[u][v] < d[v]){
            d[v] = G[u][v] + d[u];
            c[v] = cost[u][v] + c[u];
        } else if (d[u] + G[u][v] == d[v] && c[u] + cost[u][v] < c[v]) { //最短距离相同时,若花费更小,则更新c[v]
            c[v] = c[u] + cost[u][v]; 
        }
    }     
}

//考法二:顶点增加权值
int weight[maxn]; //存储每个点的权值
int w[maxn]; //存储到每个点最短路径的累计权重
for(int v = 0; v < n; v++) {
     if(vis[v] == false && G[u][v] != INF) {
         if(d[u] + G[u][v] < d[v]){
             d[v] = G[u][v] + d[u];
             w[v] = weight[v] + w[u];
         } else if (d[u] + G[u][v] == d[v] && w[u] + weight[v] > w[v]) {
             w[v] = w[u] + weight[v]; //最短距离相同时,若权重更大,则更新w[v]
         }
     }     
 }

//考法三:输出最短路径条数
int num[maxn]; //记录到每个点的最短路径条数
for(int v = 0; v < n; v++) {
     //如果v未访问 && u能到达v && 以u为中介点可以是d[v]更优
     if(vis[v] == false && G[u][v] != INF) {
         if(d[u] + G[u][v] < d[v]){
             d[v] = G[u][v] + d[u];
             num[v] = num[u];
         } else if (d[u] + G[u][v] == d[v]) {
            num[v] += num[u]; //最短距离相同时,累加num!!!
         }
     }     
 }


| Bellman-Ford & SPFA 算法

| Floyd 算法

最小生成树

练习题: HDU-1863 || HDU-1233 || HDU-1879 || HDU-1875 || HDU-3371 || HDU-1162 || HDU-4313 || POJ-1861 ||

| Prime 算法
  • Dijkstra算法和Prime算法实际上是相同的思路,不过是数组d[]所表示的最小距离含义不同而已;【Dijkstra表示到源点的最小距离,而Prime表示到树的最短距离;】 【多用数稠密图(边多)】
| Kruskal 算法
  • 思想:每次选择图中最小权值的边,如果边两边的结点在不同的连通块中,就把此边加入最小生成树中;【多用于稀疏图(边少)】
| 具体代码实现

此为一道 最小生成树的母题,已用多种思路解决!


拓扑排序
| 代码实现
const int maxn = 10010;

vector<int> Adj[maxn]; //邻接表
int in_degree[maxn]; //入度
int N; // 顶点数

//拓扑排序
bool top_sort() {
    int num = 0; //保存加入拓扑序列的顶点数
    queue<int> Q;
    for(int i = 0; i < N; i++) { //所有入度为0的结点入队
        if(in_degree[i] == 0)
            Q.push(i);
    }

    while(!Q.empty()) {
        int u = Q.front();  //取队首结点
        // printf("%d", u);  //此处可输出顶点u,作为拓扑了序列
        Q.pop();
        for(int i = 0; i < Adj[u].size(); i++) {
            int v = Adj[u][i]; //u的后集结点v
            in_degree[v]--;    //结点v的入度-1
            if(in_degree[v] == 0)  //入度减为0时入队
                Q.push(v);
        }
        Adj[u].clear();  //清空结点u的所有出边(如无必要可不写)
        num++;    //顶点数累加
    }//while

    if(num == N) return true; //拓扑排序成功
    else return false; //失败
}


| 多个排序结果的选择
  • 如果要求有多个入度为0的顶点时选择编号最小的顶点,那么把queue改成priority_queue,并保持队首元素(堆顶元素)是优先队列中最小的元素即可(当然用set也是可以的)

AOE网 (Activity On Edge)
| AOV网与AOE网
  • AOV网(Activity On Vertex):顶点表示活动,边表示活动间的优先关系的有向无环图;

  • AOE网(Activity On Edge):带权的边表示活动,而用顶点表示事件的有向无环图;边权表示完成活动需要的时间;

  • AOE网中的最长路径被称为关键路径,关键路径上的活动称为关键活动,关键活动会影响整个工程的进度

  • 理解:关键路径的定义是AOE网中的最长路径,为什么其长度会等于整个工程的最短完成时间呢?
    **A:**从时间的角度上看,不能拖延的活动严格按照时间表所达到的就是最短时间;而从路径长度的角度上看,关键路径选择的总是最长的道路。

| 最长路径
  • 对一个没有正环的图(指从源点可达的正环),如果需要求最长路径长度,则可以把所有边的边权乘以-1,然后使用Bellman-Ford算法或SPFA算法求最短路径,将所得结果取反即可;

  • 如果图中有正环,那么最长路径是不存在;但如果要求最长简单路径(每个顶点最多只经过一次),那么虽然最长简单路径存在,却无法通过Bellman-Ford等算法得到,原因是最长路径问题是NP-Hard问题(即没有多项式时间复杂度算法可解决);

  • 如果求有向无环图的最长路径长度,关键路径的求法比上面取反用SPFA等算法更快;

| 关键路径

思路:即求解DAG(有向无环图)中最长路径的方法 - 先求点,再夹边

  • 1、按照拓扑序和逆拓扑序分别计算个顶点(事件)的最早发生时间和最迟发生时间:

    • 最早(拓扑序):ve[j] = max { ve[i] + length[i->j] }j的所有入边)
    • 最迟(逆拓扑序):vl[i] = min { vl[j] - length[i->j] }i的所有出边)
  • 2、用上面的计算结果计算各(活动)的最早开始时间和最迟开始时间:

    • 最早e[i->j] = ve[j]
    • 最迟l[i->j] = vl[j] - length[i->j]
  • 3、e[i->j] == l[i->j] 的边(活动)即为关键活动

代码实现
适用于 汇点确定且唯一 的情况,以n-1号顶点为汇点为例;

#include<cstdio>
#include<algorithm>
#include<cstring>
#include<vector>
#include<queue>
#include<stack>
using namespace std;
const int maxn = 10010;

struct edge{
    int v; //终点
    int w; //边权
};

vector<edge> Adj[maxn]; //邻接表(边带权值)
int in_degree[maxn]; //入度
int N; // 顶点数
stack<int> topOrder; //栈 保存拓扑序列
int ve[maxn]; //结点的最早发生时间
int vl[maxn]; //结点的最迟发生时间


//拓扑排序 顺便求ve数组
bool topLocgicalSort() {
    queue<int> Q;
    for(int i = 0; i < N; i++) { //所有入度为0的结点入队
        if(in_degree[i] == 0)
            Q.push(i);
    }

    while(!Q.empty()) {
        int u = Q.front();  //取队首结点
        Q.pop();
        topOrder.push(u); //保存拓扑序列
        for(int i = 0; i < Adj[u].size(); i++) {
            int v = Adj[u][i].v; //u的后集结点v
            in_degree[v]--;    //结点v的入度-1
            if(in_degree[v] == 0)  //入度减为0时入队
                Q.push(v);

            //用ve[u]来更新u的所有后继结点
            if(ve[u] + Adj[u][i].w > ve[v]) { //选最大值
                ve[v] = ve[u] + Adj[u][i].w;
            }
        }
        
    }//while

    if(topOrder.size() == N) return true; //拓扑排序成功
    else return false; //失败
}

//颠倒拓扑序列得到一组合法的逆拓扑序列,求vl数组
void get_vl() {

    while(!topOrder.empty()) {
        int u = topOrder.top();
        topOrder.pop();
        for(int i = 0; i < Adj[u].size(); i++) {
            int v = Adj[u][i].v;
            //用u的所有后继结点v的vl来更新vl[u]
            if(vl[v] - Adj[u][i].w < vl[u]) { //选最小值
                vl[u] = vl[v] - Adj[u][i].w;
            }
        }//for-i
    }//while

}//get_vl

//关键路径
//使用动态规划可以更简洁地求解关键路径(11.6节)
int CriticalPath() {
    memset(ve, 0, sizeof(ve)); //ve数组初始化为0
    if(topLocgicalSort() == false) { //计算ve[]值
        return -1; //非有向无环图
    }
    fill(vl, vl + maxn, ve[N - 1]); //初始化vl数组,值为终点(汇点)的ve值    

    get_vl(); //计算vl[]值

    //遍历邻接表的所有边(代表活动),计算活动的最早开始时间e和最迟开始是按l
    for(int u = 0; u < N; u++) {
        for(int i = 0; i < Adj[u].size(); i++) {
            int v = Adj[u][i].v, w = Adj[u][i].w;
            //计算活动(边)的最早开始时间e和最迟开始时间l
            int e = ve[u], l = vl[v] - w;
            if(e == l) { //若e==l,则u->v为关键活动
                printf("%d->%d\n", u, v);
                //如果需要完整输出关键路径,保存即可(建一个邻接表,保存u->v)
            }
        }//for - i
    }//for - u

    return ve[N -1]; //返回关键路径长度
}//CriticalPath


其他扩展

  • 1、如果实现不知道汇点编号,如何比较快地获取关键路径长度?
    ve[]数组中的最大值为汇点即可;
    原因:ve[]数组的含义是时间的最早开始时间,因此所有事件中最大的一定是最后一个(或多个)时间,即汇点;
    代码操作:fill()函数之前添加一段语句,改变vl[]函数初始值即可;
	int maxLength = 0;
	for (int i = 0; i < n; i++) { //找到ve[]中的最大值
		if (ve[i] > maxLength) maxLength = ve[i];
	}
	fill (vl, vl + n, maxLength);
  • 2、如果想输出完整路径,就需要把关键活动存下来;
    方法是新建一个邻接表,当确定u->v是关键活动时,将其加入邻接表,这样最后生成的就是所有关键路径合成的,最后可以用DFS遍历来获取所有关键路径。(即可能有多条路径)

  • 3、使用动态规划的做法可以更简洁地求解关键路径,补充完成后会加入链接!!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值