晚上在研究怎么求欧拉图回路,看到 \(O(n+m)\) 版本的 HierHolzer 算法实现,让我很迷惑。
void dfs(int x){for(int i = 1;i <= 500; ++i){if(g[x][i]){--g[x][i]; --g[i][x];dfs(i);}}ans[++cnt] = x;
}
OI-Wiki 上对于这段代码的描述是这样的:
将找回路的 DFS 和 Hierholzer 算法的递归合并,边找回路边使用 Hierholzer 算法。
但代码中我并没有直观地看到“边找回路边求环”的过程,而只看到了从一个点出发,有路就往下走的朴素 dfs。所以我就产生了这样的一个问题:这样的 dfs 是否能正确地找到欧拉回路呢?
首先根据欧拉图判别法我们可以知道:欧拉图中非零度顶点是连通的,且顶点的度数都是偶数。从这个性质出发,我们在脑海中随便画出一张欧拉图,以检验算法的准确性。
接下来开始证明(由于我不会做动画,所以只能尽我所能直观地描述了):
我们将这张图上度数为偶数的节点染为白色,将度数为奇数的节点染为黑色,每经过一条边就将其删去并更新颜色。显然,最开始欧拉图上都是白色的节点。
任意选取一个点开始我们的朴素 dfs。当我们走过第一条边的时候,这条边直接连接的两个顶点(起点和当前位置)都变成了黑色。当我们继续向下 dfs 的时候,每走过一条边,原先位置的黑色节点会变回白色,而当前所在的节点会变为黑色。显然,在向下 dfs 的过程中,图上要么没有黑点,要么有且仅有两个黑点。
当我们访问的某条边连接了两个黑点时,删去这条边,整张图上所有的节点又会都变为白色,此时我们就找到了(删去了)一个经过起点的环。
到这一步 dfs 并没有结束,我们也没有考虑清楚如何记录答案,但 HierHolzer 已经初具雏形了。如果称上述过程(从起点向下 dfs 又到了起点)为一次操作,我们可以发现这次操作具有如下的性质:
在一张全是白点的图上,从任意一个度数非 \(0\) 的节点(度数当然是偶数)出发,必然能找到(删去)经过这个点的一个环。(注意欧拉图上偶度节点与“必然”能找到环的联系)
继续 dfs,此时我们正第二次位于起点的位置。回顾一下我们所制定的 dfs 规则:如果有路,就一直往下走。
按照这个规则,如果此时起点的度数大于 \(0\),那么就继续往下走。换句话说,也就是重复一次上述的操作。可想而知,每一个经过起点的环都能用上述的方法找到。每经过这样的一个过程,就会删去一个经过起点的环,起点的度数就会减 \(2\),直到它变为 \(0\)。
如果此时起点的度数为 \(0\),那么按照 dfs 的规则,我们就可以开始回溯了。注意此时回溯的顺序,如果我们是按照 \(1\to2\to3\to1\) 的顺序访问,那么我们应该按照 \(1\to3\to2\to1\) 的顺序回溯。每回溯到一个节点,这个节点的度数都必然是偶数,那么我们就可以重复一次操作,删去一个经过它的环。直到最后回溯到起点,我们就删去了图上所有的环。
显然,在回溯时将当前点添加到答案路径中,我们就可以将这若干个环拼成一个完整的路径。而由于这条路径的起点(所有边已经都删光,从起点回溯的时候)和终点(回溯到起点)都是我们所指定的那个起点,所以这条路径是一条回路。
综上,我们就找到了一条符合条件的欧拉回路。
这个过程还可以进行推广。
比如半欧拉图上找欧拉路的问题,就等价于过程中图上有且仅有两个黑点的情况。
比如每个环输出的顺序和遍历的顺序相反但起点不变,如果想要字典序最小欧拉回路,就要优先走编号小的节点,最后倒序输出。
至此我们再回看 HierHolzer 的流程,看似复杂而割裂的“找环——遍历环——找环”的过程就这样完美地融入在了一次 dfs 的过程中。
当然,如果有一天你需要脱离模板敲下完整的 HierHolzer,现场推一遍显然是不现实的。所以我们只需要坚定一个信念:
附模板题 P2731 的 AC 代码