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

定义

欧拉回路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的原因。

posted @ 2024-06-08 09:28  purinliang  阅读(28)  评论(0编辑  收藏  举报