2025-05-26 14:48:13

【图论】欧拉图、欧拉回路、欧拉通路

定义

欧拉回路Eulerian Cycle:通过图中每条边恰好一次的回路

欧拉通路Eulerian Path:通过图中每条边恰好一次的通路

欧拉图:具有欧拉回路的图

半欧拉图:具有欧拉通路但不具有欧拉回路的图

欧拉图中所有顶点的度数都是偶数。

若 G 是欧拉图,则它为若干个环的并,且每条边被包含在奇数个环内。

判别法

无向图是欧拉图当且仅当:

非零度顶点是连通的

顶点的度数都是偶数

这个条件很容易满足。

无向图是半欧拉图当且仅当:

非零度顶点是连通的

恰有2个奇度顶点

这个条件很容易满足。

有向图是欧拉图当且仅当:

非零度顶点是强连通的

每个顶点的入度和出度相等

(强连通 = 从同一个强连通的每个点出发都能到达分量上的所有点)

有向图是半欧拉图当且仅当:

非零度顶点是弱连通的

至多一个顶点的出度与入度之差为1

至多一个顶点的入度与出度之差为1

其他顶点的入度和出度相等

关于零度顶点连通与否,是否属于欧拉图的问题,看每个问题具体的定义。

Hierholzer 算法

过程

算法流程为从一条回路开始,每次任取一条目前回路中的点,将其替换为一条简单回路,以此寻找到一条欧拉回路。如果从路开始的话,就可以寻找到一条欧拉路。(或者先把缺失的那条边补上,找到一个欧拉回路,再从缺失的边那里剪开,即可。)首先要确定要求的欧拉回路或者欧拉通路存在,如果是欧拉回路存在可以从任何点开始dfs,如果只有欧拉通路存在,则要在无向图的奇数度数点开始dfs,有向图在唯一的出度比入度大1的点开始dfs。dfs完成后栈中的元素逆序就是一条欧拉回路/欧拉通路。为了保证复杂度,要在算法的过程中跳过已经遍历过的边,这个问题是这个算法最让人头疼的地方,最简单的方式是使用set,但是会涉及到在for迭代中删除set中元素的问题。

这里一般用set来存图,然后实现删边,从而使得算法的复杂度为 \(O(m\log{m})\) ,注意,stl中提供erase的容器,返回值为删除之后的下一个迭代器。也就是说,如果删除成功,则直接用erase的返回值就可以继续循环,否则直接迭代器++即可。

这个是C++ 11的标准写法:

for (auto it = my_set.begin(); it != my_set.end(); ) {

if (some_condition) {

it = numbers.erase(it);

} else {

++it;

}

}

对于有向图,下面的算法是有效的(仅限无平行边的简单情况,不推荐):

set G[MAXN];

stack stk;

void dfs (int u) {

for (auto it = G[u].begin(); it != G[u].end(); ) {

int v = *it;

it = G[u].erase(it);

dfs(v);

}

stk.push (u);

}

先判断欧拉通路存在(最多只有一对节点入度和出度不相等,并且一个是大一一个是小一)。只需要在出度唯一比入度大1的节点开始dfs,或者如果没有这样的节点,从任意节点开始dfs,然后把stk中的节点逆序输出,如果stk中的节点数量恰好等于边数+1,则欧拉通路/欧拉回路已经被找到,否则此图不连通,无解。

但是如果是无向图,这个算法要删除别的地方的set循环中的迭代器,又引入了新的不确定。

从luogu https://www.luogu.com.cn/article/kv9n9167 这篇文章学到了一个很好的方式。

使用链式前向星的方法存图,相邻的边要同时加入。然后每个节点维护一个链表的头节点,当在dfs到u,然后删除u中指向v的边时,容易知道v一定是此时链表的头节点,此时把头节点向前移动1,如果下一次再遇到u节点则不会再遍历同一个头。同时将(u,v)这一条边打上已被使用的标记。当遍历节点v时,如果遇到了指向u的那条边(反向边),那么检查这个反向边是否已经被遍历,如果是的话则继续移动头节点。这个也是链式前向星写法无法被vector写法替代的一个地方。

有向图版本

namespace EulerianPath {

int n;

struct Edge {

int u;

int v;

int next;

};

vector edge;

vector head;

void Init (int _n) {

n = _n;

edge.clear();

edge.push_back ({0, 0, 0});

head.clear();

head.resize (n + 1);

}

void AddDirectedEdge (int u, int v) {

Edge e = {u, v, head[u]};

head[u] = (int) edge.size();

edge.push_back (e);

}

int HaveEulerianCycle() {

vector outdeg (n + 1);

vector indeg (n + 1);

for (auto [u, v, next] : edge) {

++outdeg[u];

++indeg[v];

}

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

if (outdeg[i] != indeg[i]) {

return -1;

}

}

// 如果连通

return 1;

}

int HaveEulerianPath() {

vector outdeg (n + 1);

vector indeg (n + 1);

for (auto [u, v, next] : edge) {

++outdeg[u];

++indeg[v];

}

int inV = 0, outV = 0;

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

if (outdeg[i] == indeg[i]) {

continue;

}

if (outdeg[i] > indeg[i]) {

if (outV == 0) {

outV = i;

} else {

return -1;

}

} else {

if (inV == 0) {

inV = i;

} else {

return -1;

}

}

}

// 如果连通

return outV ? outV : 1;

}

vector FindEulerCyclerOrPath() {

int S = HaveEulerianPath();

if (S == -1) {

return {};

}

vector stk;

auto dfs = [&] (auto self, int u) {

for (int &i = head[u]; i;) {

auto [_u, v, next] = edge[i];

i = next; // 这一句放在这里会不会复杂度不对?是不是应该放在dfs后面?

dfs (v);

}

stk.push (u);

}

if (stk.size() == edge.size()) {

// 因为edge有一条编号为0的虚拟边,所以大小刚好相等

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

return stk;

}

return {};

}

}

上述算法未经验证

无向图版本

namespace EulerianPath {

int n;

struct Edge {

int u;

int v;

int next;

bool vis;

};

vector edge;

vector head;

void Init (int _n) {

n = _n;

edge.clear();

edge.push_back ({0, 0, 0, false});

head.clear();

head.resize (n + 1);

}

void AddUndirectedEdge (int u, int v) {

Edge e = {u, v, head[u], false};

head[u] = (int) edge.size();

edge.push_back (e);

e = {v, u, head[v], false};

head[v] = (int) edge.size();

edge.push_back (e);

}

int HaveEulerianCycle() {

vector deg (n + 1);

for (auto [u, v, next] : edge) {

++deg[u];

++deg[v];

}

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

if (deg[i] % 2 != 0) {

return -1;

}

}

// 如果连通

return 1;

}

int HaveEulerianPath() {

vector deg (n + 1);

for (auto [u, v, next] : edge) {

++deg[u];

++deg[v];

}

int oddV1 = 0, oddV2 = 0;

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

if (deg[i] % 2 == 0) {

continue;

}

if (oddV1 == 0) {

oddV1 = i;

continue;

}

if (oddV2 == 0) {

oddV2 = i;

continue;

}

return -1;

}

// 如果连通

if (oddV1 == 0 && oddV2 == 0) {

return 1;

}

if (oddV1 != 0 && oddV2 != 0) {

return oddV1;

}

return -1;

}

vector FindEulerCyclerOrPath() {

int S = HaveEulerianPath();

if (S == -1) {

return {};

}

vector stk;

auto dfs = [&] (auto self, int u) {

for (int &i = head[u]; i;) {

auto [_u, v, next] = edge[i];

Edge &cur_edge = edge[i];

Edge &back_edge = (i % 2 == 1) ? edge[i + 1] : edge[i - 1];

i = next; // 这一句放在这里会不会复杂度不对?是不是应该放在dfs后面?

if (!cur_edge.vis) {

cur_edge.vis = true;

back_edge.vis = true;

dfs (v);

}

}

stk.push (u);

}

if (stk.size() == (edge.size() + 1) / 2) {

// 因为edge有一条编号为0的虚拟边,所以大小刚好相等

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

return stk;

}

return {};

}

}

上述算法未经验证

上面这个算法对无向图存边要求反向边放在相邻位置,这个限制其实是没必要的。真正要做的其实是保证每个边只被遍历一次,那么前向星写法是可以保证的。而简单的写法用set实现只是为了方便删除反向边,但是set这样删除很容易搞出问题。但如果只是跳过重复的边其实各种vector或者list啥的都是可以做到跳过的,或者用个并查集也是可以的。前向星写法如果太麻烦的话,可以用vector的写法,每个节点存他的邻接节点,然后每次popback。这样就避免了使用迭代器。对于边的顺序如果有特殊要求的话,则直接对vector进行sort即可。仅仅是为了删除反向边的话,可以用个set存下遍历了哪些边然后删除(或者如果不想要这个常数,可以记录边的编号)。或者如果边本身是排序的,可以直接sort来删除,没必要搞得这么复杂。

有向图,求字典序最小的欧拉通路,可以有平行边,可以有自环,可以不连通

namespace EulerianPathOfDirectedGraph {

int n;

int m;

vector> G;

void Init (int _n) {

n = _n;

m = 0;

G.clear();

G.resize (n + 1);

}

void AddEdge (int u, int v) {

++m;

G[u].push_back (v);

}

// 求最小字典序的欧拉回路

void SortEdge() {

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

sort (G[i].begin(), G[i].end());

reverse (G[i].begin(), G[i].end()); // 由于后面遍历是反序的

}

}

int HaveEulerianCycle() {

vector in_deg (n + 1);

vector out_deg (n + 1);

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

for (int v : G[u]) {

++out_deg[u];

++in_deg[v];

}

}

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

if (in_deg[i] != out_deg[i]) {

return -1;

}

}

// 如果连通

return 1;

}

int HaveEulerianPath() {

vector in_deg (n + 1);

vector out_deg (n + 1);

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

for (int v : G[u]) {

++out_deg[u];

++in_deg[v];

}

}

vector in_vertex;

vector out_vertex;

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

if (in_deg[i] == out_deg[i]) {

continue;

}

if (abs (in_deg[i] - out_deg[i]) > 1) {

return -1;

}

if (in_deg[i] > out_deg[i]) {

in_vertex.push_back (i);

} else {

out_vertex.push_back (i);

}

}

// 如果连通

if (in_vertex.empty() && out_vertex.empty()) {

return 1;

}

if (in_vertex.size() == 1 && out_vertex.size() == 1) {

return out_vertex[0];

}

return -1;

}

vector FindEulerCyclerOrPath() {

int S = HaveEulerianPath();

if (S == -1) {

return {};

}

vector stk;

auto dfs = [&] (auto self, int u) -> void {

while (!G[u].empty()) {

int v = G[u].back();

G[u].pop_back();

self (self, v);

}

stk.push_back (u);

};

dfs (dfs, S);

if (stk.size() == m + 1) {

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

return stk;

}

return {};

}

}

using namespace EulerianPathOfDirectedGraph;

无向图,通过给边加上一个id,然后在弹出边的时候标记这个边为vis就好。可以有平行边,可以有自环,可以不连通

namespace EulerianPathOfUndirectedGraph {

int n;

int m;

vector>> G;

void Init (int _n) {

n = _n;

m = 0;

G.clear();

G.resize (n + 1);

}

void AddEdge (int u, int v) {

++m;

G[u].push_back ({v, m});

G[v].push_back ({u, m});

}

// 求最小字典序的欧拉回路

void SortEdge() {

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

sort (G[i].begin(), G[i].end());

reverse (G[i].begin(), G[i].end()); // 由于后面遍历是反序的

}

}

int HaveEulerianCycle() {

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

if (G[i].size() % 2 != 0) {

return -1;

}

}

// 如果连通

return 1;

}

int HaveEulerianPath() {

vector odd_vertex;

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

if (G[i].size() % 2 == 0) {

continue;

}

odd_vertex.push_back (i);

}

// D (odd_vertex);

// 如果连通

if (odd_vertex.empty()) {

// 由于是回路,所以一定是从1开始从1结束是字典序最小的

return 1;

}

if (odd_vertex.size() == 2) {

// 由于是通路,一定是从其中一个奇数点到另一个奇数点

// 和边是倒序遍历不同,点是最后才push入栈的,所以先出小的那个

return min (odd_vertex[0], odd_vertex[1]);

}

return -1;

}

vector FindEulerCyclerOrPath() {

int S = HaveEulerianPath();

if (S == -1) {

return {};

}

vector stk;

vector vis (m + 1);

auto dfs = [&] (auto self, int u) -> void {

while (!G[u].empty()) {

auto [v, id] = G[u].back();

G[u].pop_back();

if (vis[id]) {

continue;

}

// D (u, v, id);

vis[id] = true;

self (self, v);

}

stk.push_back (u);

};

dfs (dfs, S);

if (stk.size() == m + 1) {

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

return stk;

}

return {};

}

}

using namespace EulerianPathOfUndirectedGraph;

对于有一些问题,每条边只能使用一次,也是要一笔画问题,这种问题跟欧拉图一致。区别在于欧拉图要求每条边一定都要被使用,但是这个问题却不一定需要这样,可以选择某一些边不进行使用。比如 https://codeforces.com/contest/1981/problem/D 只需要选出若干条边形成一条欧拉通路即可。上面那个算法的原理(把一条通路/回路上的一个节点,替换成经过这个节点的一个通路)。如果存在太多奇数度数的点,则欧拉通路不存在,这个算法不能保证一定能找到有解,这时候要把多余的边给删掉。在这个问题中,是完全图,无向图。其实是不是完全图也无所谓,统一为无向图就可以了。

这个问题,要构造一个长度为1e6的序列,用尽可能少的不同种类的不超过3e5的正整数,并且要求相邻两个数之间的乘积互不相同。“乘积互不相同”可以知道选择质数可以做到,并且质数可以和自己相乘,所以如果是pt个质数(二分或者直接算出这个pt),则有pt*(pt+1)个不同的乘积,把质数视为顶点,则这个图是一个无向的,带自环的完全图。k个节点的完全图,每个节点有k条边(其中有1个是自环,所以每个点的度数其实是k+1),总共有k*k条无向边。如果k是奇数,那么所有的点都是偶数度数的,随便搞然后截断一下。

考虑k为偶数的情况,

每个节点的度数都是奇数,这是不好的。(除了2以外,因为题目要求的只是欧拉通路,不需要是欧拉回路)第2k+1个质数不再向下一个质数(第2k+2个)连边,这样前面的所有点都变成了偶数度数。只是这样的边数还是变小了(这样还能二分吗?)。不一定长度满足要求。当然去掉的这些边是无法使用的,这个很容易证明。这个就是我tle的原因。