[算法鉴赏][图论]欧拉路问题---Hierholzer 算法

欧拉路径与欧拉回路

1. 定义

  • 欧拉路径(Eulerian path):经过图中每条边恰好一次的路径。
  • 欧拉回路(Eulerian circuit):经过图中每条边恰好一次的回路。
  • 欧拉图(Eulerian graph):存在欧拉回路的图。

2. 性质

对于欧拉图,每个顶点的度数具有特殊性质(in_i 入度,out_i 出度,deg_i 度):

  • 有向图:欧拉回路中,每个顶点的出度等于入度(in_i == out_i)。
  • 无向图:欧拉回路中,每个顶点的度数为偶数(deg_i % 2 == 0)。

对于欧拉路径(不要求回到起点):

图类型 度数条件
有向图 ① 至多一个顶点出度比入度大 1(起点 out_i - in_i = 1
② 至多一个顶点入度比出度大 1(终点 in_i - out_i = 1
③ 其余顶点入度等于出度
无向图 至多两个顶点度数为奇数(路径起点和终点),其余顶点度数为偶数

换言之,上述性质可用于判断欧拉路径是否存在。


3. Hierholzer 算法

在图中寻找欧拉路径(或回路)需要满足:

  1. 每个顶点的度数满足上述条件。
  2. 若是欧拉路径,需确定起点。

复杂度:( O(m) )

算法流程

  1. 从某个起点开始 DFS。
  2. 对于从 x 出发的每条边 (x, y),若未访问,则标记该边,递归 y,随后将 x 入栈。
  3. 倒序输出栈中顶点即为一条欧拉路径。

不同存图方式的实现

① 邻接矩阵

int mp[N][N];
void dfs(int u) {
    for (int v = 1; v <= n; v++) {
        if (mp[u][v] > 0) {
            mp[u][v]--;
            mp[v][u]--;
            dfs(v);
        }
    }
    path.push_back(u);
}

优点:简单易写
缺点:对于内存来说还是它极端了

2. 邻接表

int in[maxn], out[maxn], del[maxn];
vector<int> pic[maxn];
stack<int> ck;

void dfs(int p) {
    for (int i = del[p]; i < pic[p].size(); i = del[p]) {
        del[p] = i + 1;
        dfs(pic[p][i]);
    }
    ck.push(p);
}

优点:简单易写
缺点:对于内存来说还是它极端了

2. 邻接表

int in[maxn], out[maxn], del[maxn];
vector<int> pic[maxn];
stack<int> ck;

void dfs(int p) {
    for (int i = del[p]; i < pic[p].size(); i = del[p]) {
        del[p] = i + 1;
        dfs(pic[p][i]);
    }
    ck.push(p);
}

del 是弧优化,用于跳过上一次遍历过的边,不用损坏原树
优点:通用主流
缺点:容易退化成为 ( O(m^2) )

3. 链式前向星

// int head[N], idx;
// bool used[M];
// struct edge{
//     int v, nxt;
// } adj[M];
auto dfs = [&](auto&& self, int u) -> void {
    for (int& i = head[u]; ~i; ) {
        int j = (t == 1 ? i / 2 + 1 : i + 1);
        if (used[j]) {
            i = adj[i].nxt;
            continue;
        }
        int v = adj[i].v, id = (t == 1 && (i & 1)) ? -j : j;

        used[j] = true;
        i = adj[i].nxt;
        self(self, v);
        ans.push_back(id);
    }
};

优点:防止卡常
缺点:逻辑没捋顺容易写挂


4. 练习及赏析

例题 1:模板题

P2731 [USACO3.3] 骑马修栅栏 Riding the Fences

题意也是十分简明,我们只要建图,然后走流程求欧拉路或是欧拉回路。需要注意的是要求输出字典序最小的,那么我们就需要对于每一个 pic[i] 排序。

#include<bits/stdc++.h>
#define ll long long
#define rep(i,a,b) for(int i=(a);i<=(b);i++)
#define dep(i,a,b) for(int i=(a);i>=(b);i--)
using namespace std;
const int maxn=3e3+7;
int n=1,m,del[maxn];
int vis[maxn][maxn];
vector<int>pic[maxn];
stack<int>path;
int deg[maxn],st=-1;

void dfs(int p)
{
	for(int i=del[p];i<pic[p].size();i=del[p])
	{
		int u=pic[p][i];
		del[p]=i+1;
		if(vis[p][u]==0)continue;
		vis[p][u]--;vis[u][p]--;
		dfs(u);
	}
	path.push(p);
}

int main()
{
	cin>>m;
	int minp=INT_MAX;
	rep(i,1,m)
	{
		int u,v;
		cin>>u>>v;
		pic[u].push_back(v);
		pic[v].push_back(u);
		deg[u]++,deg[v]++;
		vis[u][v]++,vis[v][u]++;
		minp=min(minp,min(u,v));
	}
	rep(i,1,500)sort(pic[i].begin(),pic[i].end());
	
	rep(i,1,500)
	{
		if(deg[i]&1)
		{
			st=i;
			break;
		}
	}
	if(st==-1) st=minp;
	
	dfs(st);
	while(!path.empty())
	{
		cout<<path.top()<<'\n';
		path.pop();
	}
	return 0;
}

例题 2:最短子段组合

P1341 无序字母对

一看找不到思路,实际上,我们把每两个字符直接连上有向边来进行建图,只需要找到图的欧拉路就可以找到最短的组合了。特别的,出题人并没有保证一定可以建成一个联通的图,对于此题还需要判断联通性。字典序最小的方案也需要排序求得。

#include<bits/stdc++.h>
#define ll long long
#define rep(i,a,b) for(int i=(a);i<=(b);i++)
#define dep(i,a,b) for(int i=(a);i>=(b);i--)
using namespace std;
const int maxn=150;
int n,vis[maxn][maxn],del[maxn];
vector<int>pic[maxn];
stack<int>path;
int st=200,deg[maxn];
int bcj[maxn];

void dfs(int p)
{
	for(int i=del[p];i<pic[p].size();i=del[p])
	{
		int u=pic[p][i];
		del[p]=i+1;		
		if(vis[p][u]==0)continue;
		vis[p][u]--;
		vis[u][p]--;
		dfs(u);
	}
	path.push(p);
}

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

int main()
{
	rep(i,0,149) bcj[i]=i;
	cin>>n;
	rep(i,1,n)
	{
		char a,b;
		cin>>a>>b;
		int u=(int)a,v=(int)b;
		pic[u].push_back(v);
		pic[v].push_back(u);
		vis[u][v]++;vis[v][u]++;
		deg[u]++;deg[v]++;
		st=min(st,min(u,v));
		int fu=find(u),fv=find(v);
		if(fu!=fv) bcj[fu]=fv;
	}
	int root=-1;
	rep(i,65,122)
	{
		if(deg[i]){
			int r=find(i);
			if(root==-1) root=r;
			else if(root!=r){
				cout<<"No Solution";
				return 0;
			}
		}
	}
	int odd_cnt=0,start=st;
	rep(i,65,122)
	{
		if(deg[i]&1)
		{
			odd_cnt++;
			if(odd_cnt==1) start=i;
		}
	}
	if(odd_cnt!=0&&odd_cnt!=2)
	{
		cout<<"No Solution";
		return 0;
	}
	rep(i,65,122) sort(pic[i].begin(),pic[i].end());
	dfs(start);
	if(path.size()==n+1)
	{
		while(!path.empty())
		{
			cout<<char(path.top());
			path.pop();
		}
	}
	else
	{
		cout<<"No Solution";
	}
	return 0;
}

例题 3:使用加边技巧的题目

我们需要一条 ( s \to t ) 的欧拉通路。增设一条虚拟边 ( s-t ),翻转 s、t 度数的奇偶性,此时问题转化为构造欧拉回路(全部点度数为偶数),最小化新增边花费。

数轴权重要性质

  • 边权为两点距离 ( |x-y| );
  • 将所有奇点从小到大排序之后相邻两两配对的总代价最小。
  • 配对 ([l,r]) 的同时合并区间内结点,可以减少连通块数目。

连通块搭桥

  • 奇点配对结束之后图可能多个连通块;
  • 相邻编号结点之间边权为 1,邮递员需要往返经过搭桥边,所以每条最小生成树边花费为 ( 2 \times 1 )。

解题步骤

  1. 累加所有丁香边权得到固定基础花费 base;统计每个点度数,并用并查集连通丁香边;
  2. 枚举每一个终点 t:
    1. 复制原始度数数组、连通块并查集;虚拟增加 ( s-t ),翻转两点奇偶;
    2. 搜集全部奇点,相邻配对累加花费,并且合并配对区间里面所有结点;
    3. 提取现存所有连通块,相邻连通块建边,跑 Kruskal‑MST;
    4. 连通代价等于 MST 权值 × 2;
    5. ( ans = base + 配对代价 + 连通搭桥代价 )
  3. 输出所有终点对应的答案。
#include<bits/stdc++.h>
#define ll long long
#define rep(i,a,b) for(int i=(a);i<=(b);i++)
#define dep(i,a,b) for(int i=(a);i>=(b);i--)
using namespace std;
const int maxn=2505;

int n,m,s;
ll base;
int fa[maxn],deg[maxn];

int find(int x)
{
    if(fa[x]!=x) fa[x]=find(fa[x]);
    return fa[x];
}
void unite(int x,int y)
{
    x=find(x),y=find(y);
    if(x!=y) fa[y]=x;
}

ll calc(int t)
{
    int d[maxn];
    int f[maxn];
    rep(i,1,n) d[i]=deg[i],f[i]=fa[i];
    d[s]^=1; d[t]^=1;

    vector<int> odd;
    rep(i,1,n)
        if(d[i]&1) odd.push_back(i);
    
    ll pair_cost=0;
    for(int i=0;i<odd.size();i+=2)
    {
        int l=odd[i],r=odd[i+1];
        pair_cost+=r-l;
        rep(j,l,r-1)
        {
            int x=find(f[j]);
            int y=find(f[j+1]);
            if(x!=y) f[y]=x;
        }
    }

    unordered_set<int> blk_set;
    rep(i,1,n) blk_set.insert(find(f[i]));
    vector<int> blk(blk_set.begin(),blk_set.end());
    int sz=blk.size();
    if(sz<=1) return base+pair_cost;

    int f_mst[maxn];
    rep(i,0,sz-1) f_mst[i]=i;
    auto find_mst=[&](auto&& self,int x)->int{
        if(f_mst[x]!=x) f_mst[x]=self(self,f_mst[x]);
        return f_mst[x];
    };
    ll mst_sum=0;
    rep(i,0,sz-2)
    {
        int u=i,v=i+1;
        int fu=find_mst(find_mst,u);
        int fv=find_mst(find_mst,v);
        if(fu!=fv)
        {
            f_mst[fv]=fu;
            mst_sum+=1;
        }
    }
    ll bridge_cost=mst_sum*2;
    return base+pair_cost+bridge_cost;
}

int main()
{
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin>>n>>m>>s;
    rep(i,1,n) fa[i]=i;
    base=0;
    memset(deg,0,sizeof deg);
    rep(i,1,m)
    {
        int u,v;
        cin>>u>>v;
        base+=abs(u-v);
        deg[u]++,deg[v]++;
        unite(u,v);
    }
    rep(t,1,n)
    {
        cout<<calc(t)<<" ";
    }
    return 0;
}

关于欧拉路的加边处理

通常来说我们在图论建模的时候,总是出现不连通或是并不满足欧拉路存在的图。如果这时候发现需要使用欧拉路来求解,那我们就要进行如下操作:

  1. 对于每一个连通块内的欧拉路

    连通块内未必满足欧拉路的存在,那我们只需要加重复边。比如通过加边使两个奇度点变为偶度点,这样就会使图满足欧拉路的存在性。相应的,加边时要注意新增的代价,尽可能的去构造欧拉回路,这样会更好处理。

  2. 对于连通块之间的加边处理

    在每一个连通块内都满足欧拉路的前提下,我们要使整张图都有欧拉(回)路,就要在连通块之间加上两条重复边,可以将连通块当作点处理,从 A 走到 B 之后还需要一条边返回(相应的代价要 ×2)。要使代价最小,就需要使用最小生成树 MST 得到最小开销(同样是把连通块当作点处理)。


第一次写算法教程,格式由 deepseek 负责,后期会学 markdown。

感谢观看!

—— 2026/8/12 15:59

posted @ 2026-08-12 16:17  _11Ly  阅读(3)  评论(0)    收藏  举报