【图论】欧拉图、欧拉回路、欧拉通路
定义
欧拉回路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<int> G[MAXN];
stack<int> 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> edge;
vector<int> 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<int> outdeg (n + 1);
vector<int> 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<int> outdeg (n + 1);
vector<int> 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<int> FindEulerCyclerOrPath() {
int S = HaveEulerianPath();
if (S == -1) {
return {};
}
vector<int> 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> edge;
vector<int> 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<int> 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<int> 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<int> FindEulerCyclerOrPath() {
int S = HaveEulerianPath();
if (S == -1) {
return {};
}
vector<int> 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<vector<int>> 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<int> in_deg (n + 1);
vector<int> 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<int> in_deg (n + 1);
vector<int> out_deg (n + 1);
for (int u = 1; u <= n; ++u) {
for (int v : G[u]) {
++out_deg[u];
++in_deg[v];
}
}
vector<int> in_vertex;
vector<int> 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<int> FindEulerCyclerOrPath() {
int S = HaveEulerianPath();
if (S == -1) {
return {};
}
vector<int> 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<vector<pair<int, int>>> 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<int> 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<int> FindEulerCyclerOrPath() {
int S = HaveEulerianPath();
if (S == -1) {
return {};
}
vector<int> stk;
vector<bool> 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的原因。