搜索与图论之欧拉回路与欧拉路径
前置背景
AOV&AOE
A O V AOV AOV网,顶点表示活动,弧表示活动间的优先关系的有向图。 即如果a->b,那么a是b的先决条件。
A O E AOE AOE网,边表示活动,是一个带权的有向无环图, 其中顶点表示事件,弧表示活动,权表示活动持续时间。
求拓扑序列就是 A O V AOV AOV,求关键路径就是 A O E AOE AOE
入度
入度(indegree)就是有向图中指向这个点的边的数量,即有向图的某个顶点作为终点的次数和
出度
出度(outdegree)就是从这个点出去的边的数量,即有向图的某个顶点作为起点的次数和
定义
- 欧拉回路(
Eulerian Circuit
):从图上一个点u出发不重复地经过每一条边后,再次回到点u的一条路径。 - 欧拉路径(
Eulerian Path
):从图上一个点u出发不重复地经过每一条边的一条路径(不必回到点u)。 - 欧拉图即存在欧拉回路的图,半欧拉图即存在欧拉路径的图
- 欧拉迹/欧拉通路/一笔画:通过图中每条边且行遍所有顶点的迹(每条边恰一次的途径),称为欧拉迹(Euler trail)
- 半欧拉图:具有欧拉通路但不具有欧拉回路的无向图称为半欧拉图,有且仅有两个度数为奇数的结点
- 环游:图的环游(tour)是指经过图的每条边至少一次的闭途径
- 欧拉环游/回路:经过每条边恰好一次的环游/回路欧拉环游/回路(Eular tour)
- 欧拉图:一个图若包含欧拉环游,则称为欧拉图(Euleriangraph)
- 欧拉定理:一个非空连通图是欧拉图当且仅当它的每个顶点的度数都是偶数
就像是一笔画,要求每条边只走一次,但每个点可以多次经过,而要求每个点只走一次的模型是**哈密顿环。**注意欧拉回路必须回到起点,欧拉路径则不必,可以说欧拉回路一定是欧拉路径,反之不成立
欧拉回路 | 欧拉路径 | |
---|---|---|
无向图 | 每个节点都有偶数的度,–>所有非0度的节点都是连通的,不用考虑0度的节点,因为不属于欧拉回路或欧拉路径 | 每个节点都有偶数的度或者只有两个节点有奇数的度(这个两个奇数度的节点其实是起点和终点)–>所有非0度的节点都是连通的,不用考虑0度的节点,因为不属于欧拉回路或欧拉路径 |
有向图 | 每个节点都有相同的入度和出度 | 最多只有一个顶点的入度-出度=1并且最多只有一个顶点的出度-入度=1,其他节点的出度与入度相等 |
其他结论
- 无向图为(半)欧拉图时,只需用1笔画成;无向图为非(半)欧拉图时,即奇点(度为奇数的点)数k>2,需用k/2笔画成。
- 可以用加边的方式把一个非欧拉图变成欧拉图。对于无向图来说,每个奇点都需加一个度,加的边为 奇点数/2 ;对于有向图来说,每个点都需加上入度与出度之差,加的边数为每个点入度与出度之差的绝对值之和再除以2。
无向图中的欧路径和欧拉回路
判断无向图是否有欧路径和欧拉回路
- 在欧拉路径中,每次访问一个顶点V,访问以该顶点V为端点的两条未访问过的边,在欧拉路径中的所有中间点都有偶数的度
- 在欧拉回路中,任何顶点都可以是中间顶点,因此所有的顶点都必须具有偶数度
public class EulerianGraphOne {
//判断给定的图是否是欧拉图或者半欧拉图
static class Graph {
private int V; //顶点的个数
private LinkedList<Integer> adj[];//无向图的邻接矩阵表示
Graph(int v) {//初始化话图
V = v;
adj = new LinkedList[v];
for (int i = 0; i < v; ++i) adj[i] = new LinkedList();
}
void addEdge(int v, int w) {//向图中添加边,是无向图
adj[v].add(w);
adj[w].add(v);
}
void DFSUtil(int v, boolean visited[]) {
visited[v] = true;//标记当前的顶点v为访问过的顶点
Iterator<Integer> i = adj[v].listIterator();//递归遍历当前顶点的邻居顶点
while (i.hasNext()) {
int n = i.next();
if (!visited[n])//如果被访问,该顶点跳过
DFSUtil(n, visited);
}
}
//判断非0度的顶点是否是连通的
boolean isConnected() {
boolean visited[] = new boolean[V];//初始化visited数组
int i;
for (i = 0; i < V; i++)//找到一个非0度的顶点
if (adj[i].size() != 0) break;
//如果图中没有边,返回true
if (i == V) return true;
// 从非0度的顶点开始dfs
DFSUtil(i, visited);
// 如果非0度的顶点,没有被访问,说明该顶点是孤立的联通分量,图不相连
for (i = 0; i < V; i++)
if (!visited[i] && adj[i].size() > 0)
return false;
return true;
}
/*
0 --> 该无向图不是欧拉图或者半欧拉图
1 --> 该无向图是半欧拉图即欧拉路径
2 --> 该无向图是欧拉回路 */
int isEulerian() {
// 判断非0度的顶点是否相连
if (!isConnected()) return 0;
// 计数,统计有奇数度的顶点的数量
int odd = 0;
for (int i = 0; i < V; i++)
if (adj[i].size() % 2 != 0)
odd++;
// 如果奇数度的数量超过2,该无向图不是一个欧拉图或者半欧拉图
if (odd > 2) return 0;
// 有奇数度的顶点的数量是2:半欧拉图(欧拉路径)
// 有奇数度的顶点的数量是0:欧拉图(欧拉回路)
//注意无向图中的奇数点个数不会为1
return (odd == 2) ? 1 : 2;
}
// 测试
void test() {
int res = isEulerian();
if (res == 0)
System.out.println("graph is not Eulerian");
else if (res == 1)
System.out.println("graph has a Euler path");
else
System.out.println("graph has a Euler cycle");
}
public static void main(String args[]) {
Graph g1 = new Graph(5);
g1.addEdge(1, 0);
g1.addEdge(0, 2);
g1.addEdge(2, 1);
g1.addEdge(0, 3);
g1.addEdge(3, 4);
g1.test();
Graph g2 = new Graph(5);
g2.addEdge(1, 0);
g2.addEdge(0, 2);
g2.addEdge(2, 1);
g2.addEdge(0, 3);
g2.addEdge(3, 4);
g2.addEdge(4, 0);
g2.test();
Graph g3 = new Graph(5);
g3.addEdge(1, 0);
g3.addEdge(0, 2);
g3.addEdge(2, 1);
g3.addEdge(0, 3);
g3.addEdge(3, 4);
g3.addEdge(1, 3);
g3.test();
Graph g4 = new Graph(3);
g4.addEdge(0, 1);
g4.addEdge(1, 2);
g4.addEdge(2, 0);
g4.test();
// 顶点的具有0度的无向图
Graph g5 = new Graph(3);
g5.test();
}
}
}
打印结果
graph has a Euler path
graph has a Euler cycle
graph is not Eulerian
graph has a Euler cycle
graph has a Euler cycle
有向图中的欧拉回路
上图有一条欧拉路径:{1, 0, 3, 4, 0, 2, 1}
如何判断一个有向图是欧拉图?
-
有向图满足以下条件可以断定其有欧拉回路
- 所有的非0度的顶点属于单一的强联通分量
- 每个顶点的入度=出度
public class EulerianGraphTwo {
//判断给定的有向图是否是欧拉图
static class Graph {
private int V; // 顶点数量
private LinkedList<Integer> adj[];//邻接矩阵
private int in[]; //入度数组
//初始化
Graph(int v) {
V = v;
adj = new LinkedList[v];
in = new int[V];
for (int i = 0; i < v; ++i) {
adj[i] = new LinkedList();
in[i] = 0;
}
}
//添加边,并对w这个端点的入度+1
void addEdge(int v, int w) {
adj[v].add(w);
in[w]++;
}
// 从v开始dfs遍历
void DFSUtil(int v, Boolean visited[]) {
visited[v] = true;//设置当前顶点v为访问过的顶点
int n;
// 递归遍历当前顶点v的邻居顶点
Iterator<Integer> i = adj[v].iterator();
while (i.hasNext()) {
n = i.next();
if (!visited[n])
DFSUtil(n, visited);
}
}
// 获取该有向图的镜像图
Graph getTranspose() {
Graph g = new Graph(V);
for (int v = 0; v < V; v++) {
//递归当前顶点的邻居顶点
Iterator<Integer> i = adj[v].listIterator();
while (i.hasNext()) {
g.adj[i.next()].add(v);
(g.in[v])++;
}
}
return g;
}
// 判断当前的有向图是否具有强联通分量
Boolean isSC() {
// Step 1:标记所有的顶点为未访问状态
Boolean visited[] = new Boolean[V];
for (int i = 0; i < V; i++)
visited[i] = false;
// Step 2:从第1个顶点开始做dfs
DFSUtil(0, visited);
// 如果dfs遍历结束,没有访问到所有的顶点,返回false
for (int i = 0; i < V; i++)
if (!visited[i])
return false;
// Step 3: 创建该有向图的镜像图
Graph gr = getTranspose();
// Step 4:标记所有的顶点为未访问状态
for (int i = 0; i < V; i++)
visited[i] = false;
// Step 5: 从第1个顶点对该镜像图开始做dfs,注意开始的顶点应该和源有向图相同
gr.DFSUtil(0, visited);
// 如果dfs遍历结束,没有访问到所有的顶点,返回false
for (int i = 0; i < V; i++)
if (!visited[i])
return false;
return true;
}
/*如果是该有向图有欧拉回路,返回T,反之返回F*/
Boolean isEulerianCycle() {
//检验所有的非0度顶点是否联通
if (!isSC()) return false;
//所有顶点的入度=出度
for (int i = 0; i < V; i++)
if (adj[i].size() != in[i])
return false;
return true;
}
public static void main(String[] args) {
Graph g = new Graph(5);
g.addEdge(1, 0);
g.addEdge(0, 2);
g.addEdge(2, 1);
g.addEdge(0, 3);
g.addEdge(3, 4);
g.addEdge(4, 0);
if (g.isEulerianCycle())
System.out.println("Given directed graph is eulerian ");
else
System.out.println("Given directed graph is NOT eulerian ");
}
}
}
打印结果
Given directed graph is eulerian
332. 重新安排行程
方法1:DFS朴素版
public List<String> findItinerary(List<List<String>> tickets) {
List<String> res = new ArrayList<>();
Map<String, List<String>> graph = new HashMap<>();
for (List<String> t : tickets) {
String u = t.get(0), v = t.get(1);
graph.putIfAbsent(u, new ArrayList<>());
graph.get(u).add(v);
}
for (List<String> values : graph.values()) Collections.sort(values);
dfs(graph, res, "JFK");
return res;
}
private void dfs(Map<String, List<String>> graph, List<String> res, String u) {
List<String> nexts = graph.get(u);
while (nexts != null && nexts.size() > 0) {
String v = nexts.remove(0);
dfs(graph, res, v);
}
res.add(0, u);
}
方法2:DFS+优先队列(Hierholzer算法)
PriorityQueue
已经默认是最小字典序,免去了排序的操作
public List<String> findItinerary(List<List<String>> tickets) {
Map<String, PriorityQueue<String>> graph = new HashMap<>();
for (List<String> t : tickets) {
String u = t.get(0), v = t.get(1);
graph.putIfAbsent(u, new PriorityQueue<>());
graph.get(u).offer(v);
}
Stack<String> stack = new Stack<>();
dfs(graph, stack, "JFK");
List<String> res = new ArrayList<>();
while (!stack.isEmpty()) res.add(stack.pop());
return res;
}
private void dfs(Map<String, PriorityQueue<String>> graph, Stack<String> stack, String u) {
PriorityQueue<String> nexts = graph.get(u);
while (nexts != null && nexts.size() > 0) {
String v = nexts.poll();
dfs(graph, stack, v);
}
stack.push(u);
}
方法3:Fleury算法
Map<String, List<String>> graph = new HashMap<>();
List<String> res = new ArrayList<>();
public List<String> findItinerary(List<List<String>> tickets) {
for (List<String> t : tickets) {
String u = t.get(0), v = t.get(1);
graph.putIfAbsent(u, new ArrayList<>());
graph.get(u).add(v);
}
for (List<String> values : graph.values()) Collections.sort(values);
String u = "JFK";
res.add(u);
fleuryProcess(u);
return res;
}
private void fleuryProcess(String u) {
if (graph.containsKey(u)) {
for (int i = 0; i < graph.get(u).size(); i++) {
String v = graph.get(u).get(i);
if (isValidNextEdge(u, v)) {
res.add(v);
graph.get(u).remove(v);
fleuryProcess(v);
}
}
}
}
private boolean isValidNextEdge(String u, String v) {
if (graph.get(u).size() == 1) return true;
// boolean[] visited = new boolean[graph.get(u).size()];
Map<String, Boolean> visited = new HashMap<>();
int count1 = dfs(u, visited);
graph.get(u).remove(v);
visited = new HashMap<>();
int count2 = dfs(v, visited);
graph.get(u).add(0, v);
return count1 <= count2;
}
private int dfs(String u, Map<String, Boolean> visited) {
visited.put(u, true);
int count = 1;
System.out.printf("%s\n", u);
if (graph.containsKey(u)) {
for (String adj : graph.get(u)) {
if (visited.get(adj) == null || (visited.get(adj) != null && !visited.get(adj))) {
count += dfs(adj, visited);
}
}
}
return count;
}
753. 破解保险箱
预置知识
NP完全问题
NP完全问题(NP-C问题),是世界七大数学难题之一。 NP的英文全称是Non-deterministic Polynomial的问题,即多项式复杂程度的非确定性问题。简单的写法是 NP=P?,问题就在这个问号上,到底是NP等于P,还是NP不等于
P类问题:所有可以在多项式时间内求解的判定问题构成P类问题。判定问题:判断是否有一种能够解决某一类问题的能行算法的研究课题。
NP类问题:所有的非确定性多项式时间可解的判定问题构成NP类问题。非确定性算法:非确定性算法将问题分解成猜测和验证两个阶段。算法的猜测阶段是非确定性的,算法的验证阶段是确定性的,它验证猜测阶段给出解的正确性。设算法A是解一个判定问题Q的非确定性算法,如果A的验证阶段能在多项式时间内完成,则称A是一个多项式时间非确定性算法。有些计算问题是确定性的,例如加减乘除,只要按照公式推导,按部就班一步步来,就可以得到结果。但是,有些问题是无法按部就班直接地计算出来。比如,找大质数的问题。有没有一个公式能推出下一个质数是多少呢?这种问题的答案,是无法直接计算得到的,只能通过间接的“猜算”来得到结果。这也就是非确定性问题。而这些问题的通常有个算法,它不能直接告诉你答案是什么,但可以告诉你,某个可能的结果是正确的答案还是错误的。这个可以告诉你“猜算”的答案正确与否的算法,假如可以在多项式(polynomial)时间内算出来,就叫做多项式非确定性问题。
NPC问题:NP中的某些问题的复杂性与整个类的复杂性相关联.这些问题中任何一个如果存在多项式时间的算法,那么所有NP问题都是多项式时间可解的.这些问题被称为NP-完全问题(NPC问题)。
欧拉路径/回路(Euler)与汉密尔顿路径/回路(Hamilton)
-
如果给定无孤立结点图G,若存在一条路,经过图中每边一次且仅一次,这条路称为欧拉路径
-
如果给定无孤立结点图G,若存在一条回路(起点和终点相同),经过图中每边一次且仅一次,那么该回路称为欧拉回路
-
给定图G,若存在一条路,经过图中每个结点恰好一次,这条路称作汉密尔顿路径
-
给定图G,若存在一条回路(起点和终点相同),经过图中每个结点恰好一次,这条回路称作汉密尔顿回路
欧拉回路与哈密尔顿回路的区别
「哈密尔顿回路问题」与「欧拉回路问题」看上去十分相似,然而却是完全不同的两个问题。「哈密尔顿回路问题」是访问除原出发节点以外的每个结点一次且仅一次,而「欧拉回路问题」是访问每条边一次且仅一次;对任一给定的图是否存在「欧拉回路」欧拉已给出了充分必要条件,而对任一给定的图是否存在「哈密尔顿回路」至今仍未找到满足该问题的充分必要条件。
在欧拉路径中,可能会多次访问一个顶点,在哈密尔顿路径中,可能无法穿过所有的边
B(k, n),是k元素构成的循环序列。所有长度为n的k元素构成序列都在它的子序列(以环状形式)中,出现并且仅出现一次。
例子
使用de Bruijn图的示例
目标:使用欧拉(n − 1 = 4 − 1 = 3)3-D de Bruijn图循环构造长度为2^4 = 16的B(2,4)de Bruijn序列。
两个B(2,3)de Bruijn序列和B(2,4)序列的有向图。在B(2,3)中。每个顶点被访问一次,而在B(2,4)中,每个边都被遍历一次。
此3维de Bruijn图中的每个边对应于一个四位数字的序列:三个数字分别标记该边缘要离开的顶点,其后是一个数字标记该边缘。如果一个人从000穿过标记为1的边,则一个人到达001,从而表明de Bruijn序列中存在子序列0001。精确地遍历每个边一次就是使用16个四位数序列中的每一个恰好一次。
例如,假设我们在这些顶点上遵循以下欧拉路径:
000, 000,001,011,111,111,110,101,011
110,100,001,010,101,010,100,000
这些是长度为k的输出序列:
0 0 0 0
_ 0 0 0 1
_ _ 0 0 1 1
这对应于以下de Bruijn序列:
0 0 0 0 1 1 1 1 0 1 1 0 0 1 0 1
八个顶点以下列方式出现在序列中:
{0 0 0 0} 1 1 1 1 0 1 1 0 0 1 0 1
0 {0 0 0 1} 1 1 1 0 1 1 0 0 1 0 1
0 0 {0 0 1 1} 1 1 0 1 1 0 0 1 0 1
0 0 0 {0 1 1 1} 1 0 1 1 0 0 1 0 1
0 0 0 0 {1 1 1 1} 0 1 1 0 0 1 0 1
0 0 0 0 1 {1 1 1 0} 1 1 0 0 1 0 1
0 0 0 0 1 1 {1 1 0 1} 1 0 0 1 0 1
0 0 0 0 1 1 1 {1 0 1 1} 0 0 1 0 1
0 0 0 0 1 1 1 1 {0 1 1 0} 0 1 0 1
0 0 0 0 1 1 1 1 0 {1 1 0 0} 1 0 1
0 0 0 0 1 1 1 1 0 1 {1 0 0 1} 0 1
0 0 0 0 1 1 1 1 0 1 1 {0 0 1 0} 1
0 0 0 0 1 1 1 1 0 1 1 0 {0 1 0 1}
0} 0 0 0 1 1 1 1 0 1 1 0 0 {1 0 1 ...
... 0 0} 0 0 1 1 1 1 0 1 1 0 0 1 {0 1 ...
... 0 0 0} 0 1 1 1 1 0 1 1 0 0 1 0 {1 ...
…然后我们回到起点。八个3位数序列(对应于八个顶点)中的每一个都出现两次,而十六个4位数序列(对应于16个边沿)中的每个出现一次。
正文
Q :题目问什么?
A:将0
-k^n
(k
进制)的数字组合在一起,使得该字符长度最小
Q:题目问什么?不太理解
A:确实,我翻看了评论区,有这种想法的不在少数,解释如下:
举例:n=2,k=2,密码是00 01 10 11,因此需要找到一个组合能包含这些所有的组合,当每次输入的时候,最后的n个数可以尝试解密码
答案是00110
当输入0的时候,没有操作
再次输入0,组合成00,这时候可以匹配到00
再次输入1,组合成001,这时候01可以匹配到01
再次输入1,组合成0011,这时候11可以匹配到11
再次输入0,组合成00110,这时候10可以匹配到10
答案要求的是找出一个最短的字符包含了所有可能的组合数
欧拉路径,有些编码技巧
方法1
public String crackSafe(int n, int k) {
//初始化的扩散seed
StringBuilder seed = new StringBuilder(String.join("", Collections.nCopies(n, "0")));
Set<String> vis = new HashSet<>();
vis.add(seed.toString());
int t = (int) Math.pow(k, n);//目标的组合数量
dfs(seed, vis, t, n, k);
return seed.toString();
}
private boolean dfs(StringBuilder seed, Set<String> vis, int t, int n, int k) {
if (vis.size() == t) return true;//出口条件,组合数到达目标的组合数量
String last = seed.substring(seed.length() - n + 1);//最后的n-1个字符
for (char c = '0'; c < '0' + k; c++) {
String tmp = last + c;
if (!vis.contains(tmp)) {
vis.add(tmp);
seed.append(c);
if (dfs(seed, vis, t, n, k)) return true;
vis.remove(tmp);
seed.deleteCharAt(seed.length() - 1);
}
}
return false;
}
方法2
Set<Integer> visited = new HashSet<>();
StringBuilder res = new StringBuilder();
int h, k;
public String crackSafe(int n, int k) {
h = (int) Math.pow(10, n - 1);
this.k = k;
dfs(0);
for (int i = 1; i < n; i++) res.append('0');
return res.toString();
}
private void dfs(int u) {
for (int i = 0; i < k; i++) {
int v = u * 10 + i;
if (!visited.contains(v)) {
visited.add(v);
dfs(v % h);
res.append(i);
}
}
}