Skip to content
Ayas_Lan's Blog
Go back

Edit page

图与树的主要区别在于:树是一对多的关系;图是多对多的关系。
若要保证有n个结点的无向图是连通的,则最少的方法就是形成一颗树,即N-1条边连接。

图的存储方式

邻接矩阵:

即使用二维数组来表示各个顶点之间的关系:若A[i][j] = 1,表示节点i 与节点j之间有边。

易知:

无向图的邻接矩阵是对称的,有向图的邻接矩阵可能是对称的。

当图G用邻接矩阵A表示时,顶点的度可以表示为:

有权图:

即:

A[i][j] = inf ,表示i与j之间无边;

A[i][j] = w,  表示i与j之间边权重为w;

A[i][j] = 0,  表示i=j;

邻接表:

无向图的邻接表:

即将从一个点发出的所有边表示在同一个边链表之中。

边链表中的每一个点代表每一条边,存储与之相连的节点与这条边的link
即邻接表所占空间大小与顶点数和边数均有关。

有向图的邻接表:

有向图中对于每一个顶点所发出的边,分为入边表出边表分别存储。

图的遍历:

图的遍历通常会设置辅助数组visiter[],用来保证每个节点均访问一次。

图的遍历主要分为dfs和bfs两种。

dfs:

基本流程:

从一个初始顶点出发,访问它的任一邻接节点W1,再从W1出发,访问W1的任一邻接节点,直至到达一个顶点M,满足M的所有邻接顶尖均被访问过。

然后再回退一步,访问前一个节点中没被遍历到的邻接节点。

算法上通常采用递归实现
dfs可以用来判断图中是否有环。

非递归dfs实现:

核心思想:从起点开始,先加入所有满足条件的邻居节点入栈,再对于每一个栈首元素进行深度遍历。这种方式下,每一次的出栈操作,就实现了切换到父节点的另一子节点的操作。
同时若采用邻接矩阵存储,还需要注意应该倒着入栈,以保证与dfs相同的顺序。


void dfs(int start, int n, vector<vector<int>> &edges, vector<bool> &visited)
{
    stack<int> s;
    s.push(start);
    visited[start] = true;
    while (!s.empty())
    {
        int node = s.top();
        s.pop();
        cout << node << " ";
        for (int neighbor : edges[node])
        {
            if (!visited[neighbor])
            {
                visited[neighbor] = true;
                s.push(neighbor);
            }
        }
    }
}

bfs:

基本流程:
从起始顶点出发,依次访问起始顶点的所有邻接顶点。再从这些邻接顶点出发,各个依次再访问它们的邻接顶点。
即是一种逐层遍历,搜索的流程。
通常采用队列实现。

连通分量:

非连通图的极大连通子图叫做连通分量。
一个连通图的极小连通子图叫做生成树。在n个顶点中有n-1条边。
连通分量的个数:
一个具有n个顶点的图,最少有1个连通分量——构成完全图,最多有n个连通分量——每一个点均为孤立。
弧与路径的区别
弧——在有向图中,从一个顶点到另一个顶点的有向边称为弧。
路径——途中顶点到另一个顶点的两桶序列,即可以分为多步迁移。

有向图中的分类:

常见题型:

1,若已知无向图G有N个顶点,则保证图G是连通的,至少需要多少条边:
——先构成N-1完全图,将第N个顶点连接任意一条边,则即可构成最少的边数。

2,用n个顶点沿同一方向连接形成一个环时,就构成了需要边最少的强连通图

最小生成树:

切割属性:

对于图集(V,  E),将V个点分给两个非空集合,构成两个点集S,(V - S)。


切割属性的定义:

跨越连接两个点集的最小权重边必然包含于最小生成树中。

证明:
设最小生成树T不包含最小权重边S = (u, v)。取而代之的是F边。

  1. 现在构造一颗新的生成树T‘:
  2. 从T中移除F边;
  3. 然后加入我们讨论的最小权重边S;
    现在T’的总权重 = T的总权重 - weight(F) + weight(S);
    又因为weight(S) < weight(F)。故有T’的总权重小于T的总权重。
    但我们假设的是T未最小生成树,故与题意相违背。
    接下来介绍几种查找MST的算法:

Prim算法:

核心思想:

在每一步,逐步连接当前已在MST中的顶点与尚未在MST中的顶点集合间的最小权重边,直至所有顶点都在MST中。

算法流程:

两个关键数组:

dist数组:维护每个顶点到当前MST集合的最小边权重;
(注意此点与dijkstra算法的区别,dikstra算法是维护到起点单点的最短距离,prim算法维护的是到MST集合的距离。但代码其余部分基本与dijkstra算法相似)

parent数组:记录每个新节点加入后是和哪个节点相连的;


typedef pair<int, int> PII; //(distance, node)

void prim(int start int V){
 memset(dist, int, sizeof dist);
 memset(parent, -1, sizeof parent);
 bool inMST[N];
 memset(inMST, false, sizeof inMST);
 dist[start] = 0;
 priority_queue<PII, vector<PII>, greater<PII>> pq;
 pq.push({0, start});
 while(pq.size()){
   //选取当前对MST集合距离最近的点,贪心的体现。
   int cur_node = pq.top().second;
   pq.pop();
   if(inMST[cur_node]) continue;
   inMST[cur_node] = true;
   for(auto& neighbor: graph[cur_node]){
      int v = neighbor.first;
      int weight = neighbor.second;
      //不在MST中,且找到了更小权边。
      if(!inMST[v] && weight < dist[v]){
         dist[v] = weight;
         parent[v] = u;
         pq.push({dist[v], v});
      }
   }
 }
}

时间复杂度分析:

故总复杂度为:O((V + E) logV)

Kruskal算法:

核心思想:

数据结构实现基础(并查集):

并查集用来判断两个顶点是否连通,以及用来合并两个连通分量。
主循环流程:


struct Edge{
 int u, v, weight;
 bool operator<(const Edge& other){
   return weight < other.weight;
 }
};

int find(int x){
 if(parent[x] != x){
   parent[x] = find(parent[x]);
 }
 return parent[x];
}

void union(int x, int y){
  int rootx = find(x);
  int rooty = find(y);
  parent[rootx] = rooty;
}

int kruskal(int V, vector<Edge>& edges){

  //先对边权排序

  sort(edges.begin(), edges.end());

  //初始化并查集相关数组

  for(int i = 0; i < V; ++i){

     parent[i] = i;

     rank_[i] = 0;

  }

  int mstweight = 0;

  int usededges = 0;

  for(Edge& e: edges){

     if(usededges == V - 1){

        break;

     }

     int rootU = find(e.u);

     int rootV = find(e.v);

     if(rootU != rootV){ //合并

       union(rootU, rootV);

       mstweight += e.weight;

       usededges ++;

     }

  }

  return mstweight;

}

时间复杂度分析:

由于压缩路径后的并查集操作均为反阿克曼函数,均可视为常数
故主要是排序的O(ElogV);

最短路径:

Dijkstra算法:(边权非负的单源最短路径问题)

算法步骤:

1,创建优先级队列;
2,将起点s以优先级0加入priority_queue;
​      将其他所有点以优先级inf加入priority_queue;
3,当队列不为空时,弹出顶点,并松弛从顶点出发的所有边。

核心思想:

贪心算法+松弛操作;
1,全局的最优路径可以不断地通过局部最优路径累计得到;
​    原理:当目前弹出的顶点是V1,说明V1目前是距离start最近的点。若后续操作中V1到start的距离可以被进一步更新,则说明必定存在V2到start的距离更短,前后相矛盾。
2,松弛操作:通过不断更新和改进估计的距离最优值,逼近最优策略;
​    具体:记现在的dist[now]为最佳距离,遍历当前队列弹出的顶点ver的所有边,检查是否有dist[ver] + weight[ver] [now] < dist[now],然后用来更新dist[now]的值。
NOTICE: 我们不会再用其他顶点来松弛已访问过的顶点的,因为它的最短距离已经确定过了,无需再次更新。

代码框架实现:


typedef pair<int, int> PII;  //(distance, node)

vector<vector<PII>> graph;

bool st[N];

void dijkstra(int s, vector<int>& dist){
   int n = graph.size();
   for(int i = 0; i < n;++i){
      dist[i] = inf;
   }
   dist[s] = 0;
   priority_queue<PII, vector<PII>, greater<PII>> pq;
   pq.push({0, s});
   while(pq.size()){
     int node = pq.top().second;
     int dis = pq.top().first;
     pq.pop();
     if(st[node]) continue;
     st[node] = true;
     for(int i = 0; i < graph[node].size(); ++i){
       int cur_node = graph[node][i].second;
       int cur_weight = graph[node][i].first;
       if(dist[node] + cur_weight < dist[cur_node]){
          dist[cur_node] = dist[node] + cur_weight;
          pq.push({dist[cur_node], cur_node});
       }
     }
   }
}

时间复杂度:
由于使用了优先队列,故时间复杂度为O((V+E)logV)。

特殊情况:

当出现负权边时,该算法不成立。因为负权边的存在使得 **”““当前弹出的点的最小距离不会再次改变”“**这点不成立。因为有可能前半段距离很长,但突然经过一个负权边补偿后,举例又变小。

A*算法:

本质上是Dijkstra的一种启发式改进。如果说Dijkstra算法是一种从source以同心圆的形式逐步一圈圈向外扩散。那么A 算法则是给定一个大致的前进方向,大大缩减了需要遍历检查的节点数量。

核心思想:

为每一个节点计算一个代价函数F(n),并以代价函数作为优先级来比较。
F(n) = G(n) + H(n);

具体代码框架:


typedef pair<double, int> PII;  //(f_score, 节点)

vector<vector<PII>> graph;

bool st[N];

int g[N];

int came_from[N];

double get_dist(int a, int b){

  return num ;  //结合具体计算直线距离...

}

void A_star(int start, int goal, vector<int>& path){

    int n = graph.size();

    memset(g, inf, sizeof g);

    memset(came_from, -1, sizeof came_from);

    g[start] = 0;

    double f_score = g[start] + get_dist(start, goal);

    priority_queue<PII, vector<PII>, greater<PII>> pq;

    pq.push({f_score, start});

    while(pq.size()){

       double current_f = pq.top().first;

       int node = pq.top().second;

       pq.pop();

       if(st[node]) continue;

       st[node] = true;

       //记录路径

       if(node == goal){

          path.clear();

          for(int node = goal; node != -1; node = came_from[node]){

             path.push_back(node);

          }

          reverse(path.begin(), path.end());

          return;

       }

       for(int i = 0; i < graph[node].size(); ++i){

          int edge_weight = graph[node][i].first;

          int neighbor = graph[node][i].second;

          if(st[neighbor]) continue;

          if(g[node] + edge_weight < g[neighbor]){

             g[neighbor] = g[node] + edge_Weight;

             double f_score = g[neighbor] + get_dist(neighbor, goal);

             pq.push({f_score, neighbor});

          }

       }

    }

    path.clear();

}

注意:A*算法同样无法处理负权边的图。

拓扑排序:

假设有一系列的任务,某些任务必须在某个任务完成后才能进行,怎样对这些任务的执行进行排序?

将这些任务视为一个图,每个节点代表一个任务,V ---> W表示任务V必须在W前完成。则问题转换为找到这张图的拓扑排序:
拓扑排序只有对于有向,无环的图有效。称有向无环图为DAG,或者称为AOV网络;

如何对AOV网络进行拓扑序列化:

对于任何拓扑排序,都可以将图线性化。
上述 DAG 的拓扑排序必须以 D 或 E 开头,并且必须以 F 或 C 结尾。因此,D 和 E 称为源,F 和 C 称为汇。

基本步骤:

  1. 输入网络,令n为顶点个数
  2. 在网络中选出一个没有直接前驱的顶点,并输出
  3. 删去该节点,并删去其发出的所有边
  4. 重复2,3步骤,直到:
    • 全部顶点均已输出;
    • 图中还有顶点剩余,但循环已结束,则说明存在有向环

计算拓扑排序的算法:

拓扑排序的算法求解一般为O(N + E),

基于DFS:

基于DFS的拓扑算法实际上是逆后序遍历。
树的逆后序遍历恰好是:根节点排在两个子节点的后面,故翻转过来即符合拓扑排序的要求。

基于BFS:

int h[N], e[N], ne[N], idx;
int in[N]; //存储各节点的入度
int cnt; //存储队列中的元素个数
int top[N]; //存储队列中的元素

void add(int a, int b){ //添加a -> b的边
  e[idx] = b;
  ne[idx] = h[a];
  h[a] = idx++;
}

bool topsort(){
  queue<int> q{
  for(int i = 1; i <= n; ++i){
     if(!in[i]) q.push(i);  //先将入度为0的点加入队列中。
  }
  while(q.size()){
     auto t = q.front(); //先一个个以从源点开始
     top[++cnt] = t;
     q.pop();
     for(int i = h[t]; i != -1; i = ne[i]){
        int j = e[i];

        in[j] --;  //这条边标记一次,直至其入度为0时,入队
        if(!in[j]) q.push(j);

     }
  }
  return cnt == n;  //可以判断该图是否为DAG
}

基于拓扑排序的寻找带负权边的DAG最短路:

一般Dijkstra算法无法解决负权边问题在于,提前已经假设好了某一点的最短路径,不容许改变。而拓扑排序则保证了在遍历到节点V之前,若路径上含有负权边,则一定被提前考虑过了。
其余则按照拓扑顺序逐条松弛出边即可:

vector<int> shorestPath(int source){
  vector<int> tpOrder = tpsort();//拓扑顺序
  vector<int> dist(V, INF);
  dist[source] = 0;
  
  for(int u: tpOrder){
    if(dist[u] != INF){
       for(auto &edge: adj[u]){
         int v = edge.first;
         int weight = edge.second;
         if(dist[v] > dist[u] + weight){
           dist[v] = dist[u] +weight;
         }
       }
    }
  
  return dist;
}

时间复杂度:
由于普通排序代替为拓扑排序——O(V+E)。
故DAG中的最短路径算法时间复杂度为O(V+E)。

DAG上的最长路径:

即将DAG的边权重全部取为负数。再运行一次上述的最短路算法,将结果取负即可。

拓扑的实际应用题解:

图的排序:

进行图的排序,需要考虑三点:

int topsort(){
      int in_test[N] = {0};  
memset(top, 0, sizeof top);  
memcpy(in_test, in, sizeof in);  
queue q;  
bool multiple = false;     //设置多解的状态

      for (int i = 1; i <= n; ++i){  
if(!in_test[i]) q.push(i);  
}

      if(q.size() > 1) multiple = true;  
int cnt = 0;  
while(q.size()){  
  auto t = q.front();  
  top[++cnt] = t;  
  q.pop();  
  int temp = 0;  
  for (int i = h[t]; i != -1; i = ne[i]){  
    int j = e[i];  
    in_test[j]--;  
    if(!in_test[j]){  
      q.push(j);  
       temp++;  
     }  
  }

      if(temp > 1) multiple = true;    //每次入队都要判断。  
}

      if(cnt < kind_cnt)  // 有环  
        return -1;
      if(multiple)    // 多解  
        return 0;  
return 1;   // 唯一解  
}

拓扑建图的层次分解:

在利用拓扑排序将平面图线性化的过程中,根据比较关系,往往可以将各个点分为若干类别。也可以理解为正是不同类别中的点互相连接才构成了对应的拓扑图。

相较于标准拓扑关注节点的排序,此类问题还关注层次深度。
具体应用主要见于火车停靠站等级划分问题:求出最长等级链。

那么我们应该如何分解出对应的层次划分呢?这里介绍对于点,边的分解方法

基本思路:

对于火车停靠站等级划分问题的示例:

规则:

void topu(){

    int top = 0, tt[N] = {0};
    int bo[N] = {0};
    memset(bo, 0, sizeof bo);
    memset(tt, 0, sizeof tt);
    do{
        top = 0;

        for (int i = 1; i <= n; ++i){
            if(!in[i] && !bo[i]){
                tt[++top] = i;
                bo[i] = 1;  //避免对于一个节点重复拆解
            }
        }
        for (int i = 1; i <= top; ++i){

            int t = tt[i];

            for (int j = 1; j <= n; ++j){

                if(tuopu[t][j]){
                
                    tuopu[t][j] = 0;

                    in[j]--;
                    
                }

            }
            
        }

        ans++;

    } while (top);

}
```c++
int main(){
    cin >> n >> m;
    for (int i = 1; i <= m; ++i){
        int s;
        cin >> s;
        int st[N] = {0};
        int is_stop[N];
        memset(is_stop, 0, sizeof is_stop);
        for (int i = 1; i <= s; ++i){
            cin >> st[i];
            is_stop[st[i]] = 1;
        }
        //建立 非停靠站 -> 停靠站 之间的关系。 非停站一定小于停靠站。【注意范围是始发与终点站之间的节点】
        for (int j = st[1]; j <= st[s]; ++j){
            if(!is_stop[j]){
                for (int k = 1; k <= s; ++k){
                    if(!tuopu[j][st[k]]){  // 还未建立关系。[tuopu[i][j]--> j的等级 > i的等级]
                        tuopu[j][st[k]] = 1;
                        in[st[k]]++;
                    }
                }
            }
        }
    }
    topu();
    cout << ans - 1<< endl;
    return 0;

}

关键路径:

基本背景:

在无有向环的带权有向图中,用有向边表示一个活动,用边上权值表示活动的持续时间,顶点表示事件。则这样的网络,称为AOE网络。
从源点到汇点的有向路径有多条,只有完成所有路径上的活动才能完成整个工程。

故完成整个工程所需要的时间取决于从源点到汇点的最长路径长度。这条路径长度最长的路径称作关键路径。

解决思路:

要找到关键路径,就需要找到关键活动——不按期完成就会影响整个工程完成的活动。
关键路径上的所有活动都是关键活动。
变量设置如下:

关键活动即是:l[i] = e[i]的活动。
时间余量含义:
1,若关键活动拖延时间,则整体工程也需要拖延时间。
2,l[i] - e[i]即是在不增加工程总时间的前提下,该活动可以拖延的时间
3,若l[i] - e[i] > 0,即使提前完成的时间小于等于l[i] - e[i],也不能加快整体进程。
在执行时,所有顶点都是按照拓扑排序编号的。

算法实现如下:

void criticalPath(Graph <Type> &G){
   int i, j, k;
   float e, l, w;
   int n = G.NumberOfVertices();
   float *ee = new float[n];
   float *le = new float[n];
   for(i = 0; i < n; ++i) ee[i] = 0;
  // 1. 正向计算最早时间 ee (按拓扑序)
   for (i = 0; i < n; i++) {
       int u = topoOrder[i]; // 按拓扑序取出顶点
       for (每个从u出发的邻接点v) {
           w = weight<u, v>;
           if (ee[u] + w > ee[v]) {
               ee[v] = ee[u] + w; // 更新ee[v]为最大值
           }
       }
   }
   // 2. 初始化并逆向计算最晚时间 le (逆拓扑序)
   for (i = 0; i < n; i++) le[i] = ee[汇点]; // 全部初始化为项目总工期
   for (i = n-2; i >= 0; i--) { // 逆拓扑序循环
    int u = topoOrder[i];
    for (每个从u出发的邻接点v) {
        w = weight<u, v>;
        if (le[v] - w < le[u]) {
            le[u] = le[v] - w; // 更新le[u]为最小值
        }
    }
  }
   // 3. 遍历所有边,识别关键活动
   for (每条边<u, v>, 权重为w) {
       e = ee[u];          // 活动<u,v>的最早开始时间
       l = le[v] - w;      // 活动<u,v>的最晚开始时间
       if (e == l) {       // 时差为0,是关键活动
           cout << u << ", " << v << " 是关键活动" << endl;
       }
   }
}

Edit page
Share this post on:

Previous Post
树,二叉树和森林
Next Post