最近在力扣上刷题时看到了这道题:力扣 ,这是一道欧拉路径与DeBruijn序列结合的题目,非常有趣,欧拉路径以前在离散数学中学过,但DeBruijn序列确实是第一次见,所以我特地去查了一些资料,写这篇博客主要是为了巩固欧拉路径的知识,并加深对DeBruijn序列的理解(DeBruijn序列是一个非常神奇的东西,在很多领域都有广泛的应用)。如有错误,欢迎指正。
一些定义:
欧拉通路:通过图中每条边且行遍所有顶点的迹(每条边恰一次的途径),称为欧拉通路。
半欧拉图:具有欧拉通路但不具有欧拉回路的图称为半欧拉图,有且仅有两个度数为奇数的结点。
欧拉回路:经过每条边恰好一次的回路成为欧拉回路。
欧拉图:一个图若包含欧拉回路,则称为欧拉图。
定理:(以下定理都是针对连通图)
- 一个无向图若所有的点度数都为偶数,则其一定是欧拉图(即该图中一定存在至少一条欧拉回路)。
- 一个无向图若只有两个点的度数为奇数,其他点的度数都为偶数,则其一定是半欧拉图。
- 一个有向图若所有的点的(入度 = 出度),则该图为欧拉图
- 一个有向图若只有一个点,其(出度 = 入度 + 1),且只有一个点其(出度 + 1 = 入度),其余的点(入度 = 出度),则该图为半欧拉图。
你可能会对上面的定理产生疑问,即为什么度数满足上述要求的图就一定是欧拉图或半欧拉图呢?下面我们会对其进行证明,即证明只要度数满足上面要求,那么一定可以从该图中找出一条欧拉通路,如果一个图中含有欧拉通路,那么它就一定是欧拉图(或半欧拉图)。
在证明上述定理之前,让我们先了解一下Hierholzer算法(一个求欧拉通路的高效算法)。
算法步骤:
- 选取一个顶点作为欧拉通路的起始结点,若该图为欧拉图,则起始顶点可以任选;若该图为半欧拉无向图,则起始结点可以选择俩个度数为奇数的结点中的任一个;若该图为半欧拉有向图,则起始结点必须选择(出度 = 入度 + 1)的结点。
- 深度优先搜索,访问相邻顶点,将经过的边都删除。
- 如果当前顶点已经没有其他边可以走了,即将该顶点入栈。
- 当搜索结束时,栈中顶点弹出的顺序即为欧拉路径。
注意,在上面的叙述中我只说了将走过的边删除,也就是说,同一个顶点可以重复遍历,我看有的博客说遍历过的顶点不能再遍历,那其实是错的!
我们首先考虑定理1中的情况,我们可以对满足情况1的图执行Hierholzer算法,任取一个点作为出发点,然后不停的走下去,可以证明在我们回到出发点时,不会出现走死的情况,因为现在除了出发点之外,每个点的度数都是偶数,即走进去之后一定可以走出来,且经过一个结点,该点的度数会减2,减少后度数仍为偶数,若回到出发点后还有边没有遍历到呢?我们可以擦掉所有已遍历过的边,此时剩余的图中顶点的度数一定还为偶数,因为遍历过的结点的度数都是成双成对的减少的。此时Hierholzer算法所做的操作相当于从走过的路反向的寻找第一个还连着未遍历边的结点,从该结点开始寻找,一定可以找到一条相对于该小连通图的欧拉路径,将该路径插入到刚才的路线里,并把遍历过的边擦掉,重复上述过程,一定可以找到一条欧拉回路。所以满足定理的图一定为欧拉图(或半欧拉图),剩下的几个定理的证明和这个差不多。
求解欧拉路径还有一种方法,即Fleury算法,该算法的时间复杂度较高,主要思想是选择一个起始结点v0(选择起始结点的方法和Hierholzer一样),然后选择与v0相连一条边e0,通过e0到达v1,然后从图中删除该边(除非没有选择,否则该边不使图的连通分量数量增多,即该边不能是点v0所属连通分量的割边),为什么除非逼不得已不能选择割边呢,主要是防止使图中的边不能全部遍历到。重复上述过程直到所有边全部遍历完后,路径v0e0v1e1......即为图的欧拉路径。
Fleury算法更科学的定义:
我们在每一次的递归或迭代中,假设当前走到了点vi,判断vi的边,存在边ei,删除该边后使得原本vi可以访问到的点vj现在变得不可访问了,则尽可能不走该边。如何判断该边是否是割边(或桥)呢,可以用dfs方法,看删除该边后vi可访问的点的数量是否变少了,或者用tarjan算法。
De Bruijn 序列
De Bruijn序列的一个简单解释就是给定一个n位数(n >= 2),在给定一个数k(k >= 2),该n位数的每一位都可以由[0, k - 1]之间的数来填充,而De Bruijn序列就是长度为k ^ n,既无重复又无遗漏的包含了所有n位k进制数的一个序列,De Bruijn序列是循环序列,即首尾相连。
eg:当n = 2, k = 2时,存在De Bruijn序列0011,其包含了00,01,11,10所有的2位2进制数的情况。
我们可以知道的是,对于任意给定的n和k,De Bruijn序列都存在,且不止一种。
那么怎样寻找De Bruijn序列呢,我们可以构造一个合适的欧拉图来寻找De Bruijn序列.
具体方法:我们把所有n - 1位k进制数当做图的结点放入图中,然后将1位k进制数当做边,当从结点走过一条边会达到另一结点,两个结点之间的关系是将边的值加入到出发结点的末尾,并删掉出发结点开头的数所得到的值为结束结点的值,此处我们假设n = 3,k = 2;则有结点00,01,它们之间存在着以条值为1,由00指向01的边,因为00走过边1后的值为001,然后删掉开头的0,则变为01。对于每个结点,我们都会有k条出边,因为任意一个n-1位k进制数在末尾添加一位后都可以产生k个不同的n位k进制数。同理每个n-1位k进制数都会有k条入边(一个结点若能转化为另一个结点,则这连个结点的形式一定是xab--(y)-->aby,其中x可以有k种不同的值,所以aby有k条入边),可以得知,我们构建的图的每一个顶点的 入度 = 出度 = k,且该图一定是连通的,所以该图一定是有向欧拉图。我们任选一个顶点,沿着该图的欧拉路径(每经过一条边,将该边的值加入字符串db),得到的db序列一定是一个长度为k ^ n的De Bruijn序列。(为什么得到的序列一定包含了所有的n位k进制数呢,我们首先可得一共有k ^ n个不同的n位k进制数,我们构造的图中一共有k ^ n条边,且由于图中的每个结点的值都不同,所以它们末尾加上一位数所产生的n位k进制数都不相同,所以该图可以产生k ^ n个不同的n位k进制数,刚好囊括了所有情况,而我们又是沿着欧拉路径走的,一定可以经过所有的边刚好一次。为了理解,我们可以在开始之前先将起始结点的值存入db中,这样,每经一条边,将该边的值存入db中后,db的末尾n位一定是一个新的n位k进制数,db的末尾n - 1位一定是当前结点的值,在经过所有k ^ n条边后,我们产生了所有k ^ n个不同的n位k进制数,则我们的db序列是一个长度为n + k ^ n - 1的序列,因为开始的时候将起始结点加入了db,该序列可以看做一个非循环的De Bruijn序列,由于我们最终回到了起始结点,所以db序列的最后n - 1位数为起始结点的值,和db序列开头的值,我们将该序列的起始或末尾的n - 1位数删掉,得到的即为De Bruijn序列,但是对于开头的题目而言,上述的非循环De Bruijn序列才是我们真正要找的序列)
解题代码(用Hierholzer):
注意,由于每个结点的边的数量和规律都相同,所以不用花费专门的空间来存储该图。且在算法实现中,我把找寻欧拉回路和生成De Bruijn序列同时进行,所以得到的其实是欧拉路径所求得序列的逆转序列,但这不影响结果的正确性。
class Solution {
public:
string ans;//保存结果
unordered_set<int> visited;//判断一个值是否已被生成过
int d, m;
void dfs(int node){
for(int i = 0; i < d; i++){
int t = node * 10 + i;//生成n位k进制数
if(!visited.count(t)){//若该数不在visited数组中,说明该数没有被生成过,即该边没有被遍历过
visited.insert(t);
dfs(t % m);//删除开头的值,使之称为一个n-1位k进制数
ans += (i + '0');//说明此时已经从t % m返回,即t % m没有可走的边了,所以将该路径压入栈
}
}
}
string crackSafe(int n, int k) {
m = pow(10, n - 1);
d = k;
dfs(0);
ans += string(n - 1, '0');//将起始节点值入栈
return ans;
}
};
之前看过一个文档觉得特别经典,我把截图放在这防止以后链接失效,文档的链接我放在参考资料里了。