內容概要:
- 歐拉回路和歐拉路徑
- Hierholzer算法求解歐拉回路和歐拉路徑
- 歐拉回路的應用:LeetCode753破解密碼箱
- 德布魯因序列
歐拉圖
問題來源:1736年瑞士數學家歐拉發表論文討論哥尼斯堡七橋問題。歐拉圖問題也是圖論研究的起源。
基本概念:
圈:任選圖中一個頂點爲起點,沿着不重複的邊,經過不重複的頂點爲途徑,之後又回到起點的閉合途徑稱爲圈。
歐拉路徑:通過圖中所有邊一次且僅一次遍歷所有頂點的路徑稱爲歐拉(Euler)路徑;
歐拉回路:通過圖中所有邊一次且僅一次行遍所有頂點的迴路稱爲歐拉回路;
歐拉圖:具有歐拉回路的圖稱爲歐拉圖;
半歐拉圖:有歐拉路徑但沒有歐拉回路的圖稱爲半歐拉圖。
歐拉圖與半歐拉圖的判定:
- G是歐拉圖G中所有頂點的度均爲偶數G是若干個邊不重的圈的並。
- G是半歐拉圖G中恰有兩個奇數度頂點。
注意:以上判定是基於無向圖,有向歐拉圖的判定與此類似,這裏先略去,在討論有向圖時會補充。另外,研究無向歐拉圖,可以有平行邊,這裏也不考慮。
由於歐拉圖有嚴格的拓撲學性質和簡明的充分必要條件,所以歐拉圖的判定和歐拉回路的求解要比哈密頓圖的判定和哈密頓迴路的求解簡單的多。
歐拉圖判定算法
首先判定是否連通,然後遍歷每個頂點檢查頂點的度是否是偶數即可。
public boolean hasEulerLoop(){
// 歐拉回路存在的前提是連通,首先判斷連通性
CC cc = new CC(G);
if(cc.count() > 1) return false;
for(int v = 0; v < G.V(); v ++)
if(G.degree(v) % 2 == 1)
return false;
return true;
}
歐拉圖中求解歐拉回路
方法1:回溯法
遍歷所有邊,每遍歷一個邊則刪除該邊繼續遍歷,如果中間過程還沒有遍歷所有邊就無法繼續遍歷了,則往前回溯繼續遍歷。該算法時間複雜度是指數級別。
方法2:弗羅萊(Fluery)算法
設G是一無向歐拉圖,Fluery算法求解一條歐拉回路算法如下:
- (1) 任取,令.
- (2) 設已經行遍,按下面方法來從中選取
(a)與相關聯;
(b)除非無別的邊可供行遍,否則不應該爲中的橋。 - (3) 當(2)不能再進行時,算法停止。
當算法停止時所得簡單迴路爲中一條歐拉回路。
Fluery算法的原則是當來到某個頂點有多條邊可以選擇的時候,除非無路可選否則不走橋(刪除走過的邊之後)。這個算法的時間複雜度是級別的。
方法3:Hierholzer算法(插入迴路法)
該算法的思想是一步步構造出迴路。由歐拉圖的充要條件:G是歐拉圖G是若干個邊不重的圈(環)的並,我們可以先找到一個環,而剩下的邊一定還存在環,且這兩個部分必有公共點,從而可以形成更大的環,這樣直到包括所有邊,即可找到歐拉回路。該算法時間複雜度爲,非常高效。
設置兩個棧,curPath和loop。算法過程:
(1)選擇任一頂點爲起點,入棧curPath,深度搜索訪問頂點,將經過的邊都刪除,經過的頂點入棧curPath。
(2)如果當前頂點沒有相鄰邊,則將該頂點從curPath出棧到loop。
(3)loop棧中的頂點出棧順序,就是從起點出發的歐拉回路。
(不過其實loop的入棧順序也是歐拉回路,剛好和我們遍歷的歐拉回路方向反向,所以loop也可以設計爲ArrayList或隊列。)
import java.util.ArrayList;
import java.util.Stack;
public class EulerLoop {
private Graph G;
public EulerLoop(Graph G){
this.G = G;
}
public boolean hasEulerLoop(){
// 是否存在歐拉回路
CC cc = new CC(G);
if(cc.count() > 1) return false;// 歐拉回路存在的前提是連通,首先判斷連通性
for(int v = 0; v < G.V(); v ++)
if(G.degree(v) % 2 == 1)
return false;
return true;
}
public ArrayList<Integer> result(){
// 返回歐拉回路結果
ArrayList<Integer> res = new ArrayList<>();// 充當Loop棧
if(!hasEulerLoop()) return res;
Graph g = (Graph) G.clone();// 用 G 的副本 g 尋找歐拉回路
// 刪除 g 的邊不會影響 G
Stack<Integer> stack = new Stack<>(); // curPath 棧
int curv = 0;
stack.push(curv);
while (!stack.isEmpty()){
if(g.degree(curv) != 0){
// 度不爲0說明當前頂點連的還有邊,也就是還有路可走
stack.push(curv);
int w = g.adj(curv).iterator().next(); // 可迭代列表的第一個元素,即取g的任意鄰點
g.removeEdge(curv, w);
curv = w;
}else {
// curv 到不了其它頂點,則已經找到一個環
res.add(curv);
curv = stack.pop();
}
}
return res;
}
public static void main(String args[]){
Graph g = new Graph("g.txt");
EulerLoop el = new EulerLoop(g);
System.out.println(el.result());
}
}
半歐拉圖求解歐拉路徑
半歐拉圖求解歐拉路徑同樣基於Hierholzer算法,由半歐拉圖的充分必要條件:G是半歐拉圖G中恰有兩個奇數度頂點。我們可以先找到這兩個奇數度頂點,從其中任意一個開始,按照尋找歐拉回路相同的步驟,當遍歷完所有邊得到的就是一個歐拉路徑。
import java.util.ArrayList;
import java.util.Stack;
public class EulerPath {
private Graph G;
ArrayList<Integer> startAndEnd; // start end,歐拉路徑的起點和終點
boolean isEuler = false, isHalfEuler = false;
public EulerPath(Graph G){
this.G = G;
startAndEnd = new ArrayList<>();
}
public void hasEulerPath(){
// 是否存在歐拉路徑
CC cc = new CC(G);
if(cc.count() > 1) {
isEuler = false;// 首先判斷連通性
isHalfEuler = false;
}
for(int v = 0; v < G.V(); v ++)
if(G.degree(v) % 2 == 1)
startAndEnd.add(v);
if(startAndEnd.size() == 0){
isEuler = true; isHalfEuler = true;
}else if(startAndEnd.size() == 2){
isHalfEuler = true;
}
}
public ArrayList<Integer> result(){
// 返回歐拉路徑結果
ArrayList<Integer> res = new ArrayList<>();// 充當Path棧
hasEulerPath();
if(!isHalfEuler) return res;
Graph g = (Graph) G.clone();
// 刪除 g 的邊不會影響 G
Stack<Integer> stack = new Stack<>(); // curPath 棧
int curv = 0;
if(startAndEnd.size() == 2)
curv = startAndEnd.get(0);
stack.push(curv);
while (!stack.isEmpty()){
if(g.degree(curv) != 0){
// 度不爲0說明當前頂點連的還有邊,也就是還有路可走
stack.push(curv);
int w = g.adj(curv).iterator().next(); // 可迭代列表的第一個元素,即取g的任意鄰點
g.removeEdge(curv, w);
curv = w;
}else {
// curv 到不了其它頂點,則已經找到一個環
res.add(curv);
curv = stack.pop();
}
}
return res;
}
public static void main(String args[]){
Graph g = new Graph("g.txt");
EulerPath ep = new EulerPath(g);
System.out.println(ep.result());
}
}
歐拉回路的應用
LeetCode753破解保險箱
對題意的說明:
題目的含義是現在有一個有記憶功能的密碼箱,其密碼有n
位,每一位是[0,k)
之間的整數,即一個k
進制數,在利用密碼箱記憶特性的基礎下如何找到最短的串,使得該串的相鄰n位包含所有的密碼組合。
我們並不知道密碼是多少,想要開箱子,只能去試,密碼空間共有k^n
個密碼,每個密碼n
個字符。以n=3,k=2
爲例:所有可能的密碼爲000,001,010,011,100,101,110,111
,我們可以輸入000 001 010 011 000 101 110 111
,由於這個序列的鄰3位包含了所有可能密碼,所以可以打開密碼箱。但其實輸入010011101
也可以打開密碼箱,因爲在密碼箱的記憶特性下,末3位同樣遍歷了密碼空間。現在問題是如何找到最短的這樣的串。問題抽象出來就是:
如何構造一個長度爲n的k進制序列,使得所有長度爲n的序列都在它的子序列中出現並且僅出現一次。
這和組合數學中的德布魯因序列(De Bruijn sequence)幾乎一模一樣,德布魯因序列B(k, n),是k元素構成的循環序列。所有長度爲n的k元素構成序列都在它的子序列(以環狀形式)中,出現並且僅出現一次。
現在來求解這個題,構造一個有向圖,具體求解描述如下:
- 這個圖有
k^(n-1)
個頂點,每個頂點是一個n-1
長度的k
進制序列; - 每個頂點有
k
條入邊和k
條出邊,k
條邊分別代表數字0,1,2,...,k-1
; - 顯然這個圖是一個歐拉圖,找到圖中的一條歐拉回路,將回路上頂點和邊的數字按遍歷順序寫出來就是本題的解。
爲什麼要這樣來構造圖呢,首先這個歐拉回路恰好對應De Bruijn序列,De Bruijn序列最後的長度是k^n
,這正是密碼空間中所有可能密碼的數量,構造這樣一個有向圖後,我們讓每個頂點沿着一條邊到另一個頂點後轉移到這個頂點表示的狀態,以k=2,n=3
爲例:
這樣每個頂點加上與它相連的一條出邊恰好是一個密碼空間中的密碼,所有可能的這樣的頂點和邊的組合個數是k^(n-1) * k = k^n
,也就是是密碼空間中所有可能密碼的數量,如果能找到一條遍歷到所有的邊一次且僅一次的迴路,就意味着這個序列出現過密碼空間中的所有密碼一次且僅一次,不可能有比它更短的迴路了,所以我們求這個圖的一條歐拉回路就好了。
import java.util.Collections;
import java.util.TreeSet;
class Solution {
TreeSet<String> visited;
StringBuilder res;
public String crackSafe(int n, int k) {
if(n == 1 && k == 1) return "0";
visited = new TreeSet<>();
res = new StringBuilder();
// 從頂點 00..0 開始
String start = String.join("", Collections.nCopies(n-1, "0"));;
findEuler(start, k);
res.append(start); // 迴路添加最後的end頂點,end 就是 start
return res.toString(); // return new String(res);
}
public void findEuler(String curv, int k){
for(int i = 0; i < k; i ++){
// 往頂點的 k 條出邊檢查,頂點加一條出邊就是一種密碼可能
String nextv = curv + i;
if(!visited.contains(nextv)){
visited.add(nextv);
findEuler(nextv.substring(1), k);
res.append(i);
}
}
}
}