4. 图论起源与欧拉回路

 


图论起源

图论是生活中的一个抽象的概念或者说是工具,围绕多对多的连接关系,计算机科学家们设计了很多算法,而后把很多实际问题抽象出来,用图论的算法解决多对多的信息或者数学问题。

关于图的算法有很多,但最重要的是图的遍历算法,也就是如何从一个点出发,通过连接的线访问图的各个点。

图要是学好了,您可以跨学科研究:社会网络、数据分析、离散数学、网络爬虫、道路规划、机器人导航、系统动力学…而系统动力学是真的好玩。
 


图,这种结构起源于大数学家欧拉,我今天要拿一些专业名称来唬人,请不要揭穿我。

比如说,奇点偶点起点终点顶点度数

“请大家翻到 95 页,思考 3m… (其实读一下题就好) ”,小学数学老师如是说。

这是小学数学书上的一道题,好像是二年级… (比划过,有些印象)

这道题是图论的起源,由大数学家 莱昂哈德·欧拉 采用一笔画的解法证明在当前给定的条件下,不能走遍哥尼斯堡七桥。

我们来走一遍,试试。


起点:陆地A的店主桥,不重复走过的桥并且全部走过,终点:内福夫岛

  • [陆地A的店主桥]出发,经过[店主桥], 抵达[内福夫岛];(但没有走过所有桥,继续)
  • [内福夫岛]出发,经过[铁匠桥],抵达[陆地A];
  • [陆地A]出发,经过[木桥],抵达[陆地C];
  • [陆地C]出发,经过[密桥],抵达[内福夫岛];
  • [内福夫岛]出发,经过[绿桥],抵达[陆地D];
  • [陆地D]出发,经过[吉布莱茨桥],抵达[内福夫岛];

漏了[高桥]!!!

生活在那里的人们,晚上散步也会去试试说不定就全部走了一遍,可惜就是没走出来。

后来,欧拉再次把上面的图简化为几何图形!!


连接方式不变,简化后的连接方式,就是【图】(Graph)。

A、B、C、D 不在是陆地了,是叫【顶点】,而 7 座桥现在叫【边】。

解决这个问题其实非常简单,对于这个几何图形,我们只需要考虑入口和出口。

您看,顶点A是不是有3条线连在A的圈圈上,所以,顶点A3个出入口,度数为 3

由此类推,其他顶点:

  • B:5
  • C:3
  • D:3

度数严谨的定义:顶点所关联的边数。

因为每个顶点都关联多条边(多个度数),但每通过顶点一次,这个顶点就减去 2 个度数。

如果想一次性走成,必须满足所有顶点都是偶数或者只有俩个奇点。

我们顺着图走,边走边减。

每经过一个顶点,就划去 2 条边,边走边减。 因为一次减2条边,每个顶点的奇偶性都不会变。

[陆地A]出发,A的度数减1只有出口 -> 经过[店主桥]到达[陆地B]B的度数减2因为经过入口和出口;

其余步骤同理。验证结果如下,

  • 起点和终点相同,则 A、B、C、D 顶点度数都要为偶数
  • 起点和终点不同,则 A、B、C、D 起点、终点度数为奇数,其余顶点度数要为偶数

而我们不需要回到顶点,所以是第2种情况,即起点和终点不同。

起点和终点不同,走遍七桥并不重复,结果是所有顶点度数都为0

如果还存在大于0的顶点,那就还有没走过的边。

那倒推过来,起点、终点顶点度数为奇数其余顶点度数要为偶数,才能全部走完。

  • A:3
  • B:5
  • C:3
  • D:3

对比一下,发现原图的七桥并不匹配,所以不可能在不走重复桥的前提下,走遍7桥。

 


一笔画问题通解(回溯)

这种每经过一个顶点,就划去 2 条边,边走边减 的方法也叫【一笔画】。

  • 先计算出图的度数、每个顶点的度数;
  • 模拟走的过程,每经过一个顶点,就划去 2 条边,边走边减;
  • 当 走过顶点数 等同 总顶点数 时,结束;
  • 如果没有路了,但还有顶点没走,要退回继续尝试。

回溯算法:

#include <stdio.h>
const int Q = 4;
 
/* 第一步,建模 [ 邻接矩阵 ]  */
int graph[Q][Q]= {
	0,1,1,0,
	1,0,1,1,
	1,1,0,1,
	0,1,1,0,
}; 										// 顶点C在七桥中是只能走一边,所以C点要全部赋值为0即无解情况

/* 第二步,统计每个顶点度数和奇点个数 */
int a[Q];		                        // 记录Q个顶点的度数
int total;								// 顶点数总计
int edge;								// 记录走过的顶点

int draw(int v) {
  int k = 0;
  if( total == edge ) return 1;  
  // 递归结束条件:走过顶点数 等同 总顶点数
  
  for(int i = 0; i < Q; i++) {          // 1 - Q-1
    if( graph[v][i] == 1 ) {
      k = 1;
      graph[v][i] = 0; 
      graph[i][v] = 0;
      edge += 2;
      
      if( draw(i) ) {                   // 如果递归能走下去
        printf(" -> %c", i+65);         // 输出顶点
        return 1;
      } else {                          // 否则,悔一步要复原
        graph[v][i] = 1;
        graph[i][v] = 1;
        edge -= 2;
        k = 0;
      }
    }
  }
  if(k == 0) return 0;
}
 
int main(int argc, char *argv[]) {
  int v = 1; 	// 若没有奇点则从顶点1开始
  int k = 0;	// 奇点个数总计
  
  for( int i = 0; i < Q; i ++ ) {
    for( int j = 0; j < Q; j ++)
      if( graph[i][j] == 1 )   a[i]++;     // 统计每个顶点的度
    
    total += a[i];   					   
    if( a[i]%2 == 1 ) {                    // 判断当前顶点度数是否为奇点
      k ++;
      v = i;
    }
  }
  
  if( k > 2 )            
    printf("No solution, k > 2\n");    // 奇点大于2,无解情况
  else{
    draw(v);                           // 从俩个奇点任意一点出发
    printf(" -> %c ",v+65);
  }
  return 0;
}

回溯法是一种【选优搜索法】,按照选优条件深度优先搜索,以达到目标。当搜索到某一步时,发现原先选择并不是最优或达不到目标,就【退回】一步重新选择,走不通就退回再走

回溯复杂度:

  • 时间复杂度: Θ ( 2 n ) \Theta(2^n) Θ(2n)
  • 空间复杂度: Θ ( n ) \Theta(n) Θ(n)
     

欧拉回路

欧拉回路就是欧拉证明【哥尼斯堡七桥】问题无解后而得名的,欧拉回路即从一个点出发,沿着边行走,经过每个边恰好一次,最后再回到出发点。

欧拉回路,其实就是对【哥尼斯堡七桥】问题的描述。

目前求解欧拉回路,一共有三种算法:

  • 回溯: Θ ( 2 n ) \Theta(2^{n}) Θ(2n)
  • Fleury: Θ ( e 2 ) \Theta(e^{2}) Θ(e2)
  • Hierholzer: Θ ( v + e ) \Theta(v+e) Θ(v+e)

回溯法,我们已经实现了,就是上面的那个。

解决欧拉路径问题:

  • 先判断回路是否是欧拉回路
  • 再用最优的算法寻找欧拉回路

 


欧拉回路的存在性

欧拉回路即从一个点出发,沿着边行走,经过每个边恰好一次,最后再回到出发点。

欧拉证明欧拉回路的方法很清晰,主要看度数。

每经过一个顶点,就划去 2 条边,边走边减

欧拉回路想让每条边都走一遍,回到原点,那每个点都必须有进有出。

那每个点的相连边数(度数),必须是偶数。

如果图存在欧拉回路,每个点的度数是偶数。

那如果一个图,它的每个点的度数是偶数,图一定存在欧拉回路吗?

如果这个图是联通的,而且是无向图,就成立 — 其余的就不成立。


如果我们要判断一个图是否存在欧拉回路,就可以采用这个简明的证明:

  • 判断图是否是联通的, D F S 、 B F S DFS、BFS DFSBFS 都可以
  • 如果联通分量 > 1 > 1 >1,条件就不成立,不存在欧拉回路
  • 遍历图的所有顶点,如果某个点是奇数,也不存在欧拉回路
bool has_euler_loop( adjMatrix * G ){
	if( DFS( G ) > 1 )   // DFS 返回 联通分量的个数
		return false;

	for (int i=0; i<G->n; i++ )
		if( degree(G->n) % 2 == 1 )    // degree 返回 顶点的度数
			return false;

	return true;
}

欧拉路径的存在条件:

  • 无向图:仅有两个点度数为奇数,且是连通图(用并查集判断);
  • 有向图:有两个点可以入度出度不相等(差不大于一),即起点终点;起点入度小于出度,终点入度大于出度,且是连通图(用并查集判断)。

欧拉回路的存在条件:

  • 无向图:所有点度数都为偶数,且是连通图(用并查集判断);
  • 有向图:所有点的出度入度都相等;从任意一点都可实现,且是连通图(用并查集判断)。

 


Fleury寻找欧拉回路(贪心)

思路:从某个点开始,遍历边,但有一个挑选条件,要先选【多边】的点走,不走单边的顶点。

贪心就体现在,要先选【多边】的顶点走,因为对比回溯,贪心就在于看出来走【多边】比走单边要好得多,避免做无用功。

如果一个顶点的邻边只有一条,也被称为【桥】(走这条路会把图分成俩部分)。

因为就像现实生活里的桥一样,如果拆掉河水上的桥,那把这张陆地(图)分割为俩个部分,不联通所以走不通。

Fleury算法步骤:

  • 对当前点的邻边,判断一下是否是【桥】
  • 如果存在多边,就不走【桥】,选一条多边的走就可
  • 否则,只能走【桥】
  • 其余的,和回溯法相同

输入:

5 6
1 2
1 3
2 3
3 4
3 5
4 5

输出:

1 3 5 4 3 2 1

完整代码:

#include <iostream>
using namespace std;

#define M 202
typedef long long ll;
struct stack {
    int top, node[M];
}s;

int e[M][M],n;
void dfs(int x) {
    int i;
    s.node[++s.top]=x;
   
    for(i=0;i<n;i++) {
        if(e[i][x]>0) {
            e[i][x]=e[x][i]=0;  //删除这条边
            dfs(i);
            break;
        }
    }
}
void fleury(int x) {
    int i,flag;
    s.top=0; s.node[s.top]=x;
    while(s.top>=0) {
        flag=0;
        for(i=0; i<n; i++) {
            if(e[s.node[s.top]][i]>0) {
                flag=1;
                break;
            }
        }
        if(!flag) printf("%d ",s.node[s.top--]+1);
        else dfs(s.node[s.top--]);
    }
    puts("");
}

int main( )
{
    int i,j,u,v,m,degree,num=0,start=0;
    scanf("%d %d",&n ,&m);
	memset(e, 0, sizeof(e));
    for(i=0;i<m;i++) {
        scanf("%d %d",&u, &v);
        e[u-1][v-1] = e[v-1][u-1] = 1;
    }
    for(i=0; i<n; i++) {
        degree = 0;
        for(j=0;j<n;j++)
            degree+=e[i][j];
        if(degree & 1) {
            start=i;
            num++;
        }
    }
    if(num==0||num==2) fleury(start);
    else printf("No Euler path\n");
    return 0;
}

Fleury复杂度:

  • 时间复杂度: Θ ( e 2 ) \Theta(e^{2}) Θ(e2)
  • 空间复杂度: Θ ( n ) \Theta(n) Θ(n)

Fleury 的时间复杂度还可以优化为 Θ ( e ∗ l o g e 3 ) \Theta(e*loge^{3}) Θ(eloge3),具体的优化方法比较复杂,需要查找相应的论文。

 


Hierholzer寻找欧拉回路(模拟)

思路:从一个点出发,随意走。

其实就是把上面的数学证明 【一个无向联通图,它的每个点的度数是偶数,图一定存在欧拉回路】的过程,给模拟下来了。

  • 选择任一顶点为起点,遍历所有相邻边;
  • 深度搜索,访问相邻顶点。将经过的边都删除;
  • 如果当前顶点没有相邻边,则将顶点入栈;
  • 栈中的顶点倒序输出,就是从起点出发的欧拉回路。
void remove_edge( adjMatrix *nG, int v, int w )
{
if( nG->kind == DN || nG->kind == UDN )  // 无向图
    nG->matrix[v][w] = nG->matrix[w][v] = 0;
    else
        nG->matrix[v][w] = 0;
}

void __hierholzer(adjMatrix *nG, std::stack<int>&s, int v){      
    for(int i=0; i<nG->n; i++)
        if(nG->matrix[v][i]){
            remove_edge( nG, v, i );
            __hierholzer(nG, s, i);
                       // 不用恢复边!相当于删除边
        }
    s.push(v);         // 出栈时记录
}

// 深拷贝:保护数据
void hierholzer( adjMatrix *G ){
	deep_copy(G, nG);
	std::stack<int> s;
    __hierholzer( nG, s, 0 );
   
    while(!s.empty()){
    printf("%d ",s.top());
    s.pop();
	}
	printf("\n");
}

输入:

5 6
1 2
1 3
2 3
3 4
3 5
4 5

输出:

1 2 3 4 5 3 1

Hierholzer复杂度:

  • 时间复杂度: Θ ( v + e ) \Theta(v+e) Θ(v+e)
  • 空间复杂度: Θ ( n ) \Theta(n) Θ(n)

这个算法会删除边,可以用深拷贝优化,保护原始数据不受影响。
 


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值