引言:从哥尼斯堡七桥到一笔画游戏
一笔画是图论中最经典的问题之一,起源于18世纪著名的哥尼斯堡七桥问题。当时普鲁士的哥尼斯堡城被普雷格尔河分为四块陆地,七座桥连接着它们。市民们热衷于一个游戏:能否从任意一块陆地出发,走过每一座桥恰好一次,最终回到起点?
1736年,欧拉发表了关于这一问题的论文,不仅证明了该路径不存在,更开创了图论这一数学分支。如今,一笔画已演变为各类益智游戏的核心玩法——给定一个由点和线构成的图形,判断是否能一笔将其画出,且每条边只经过一次。
本文将用Java实现一笔画问题的完整求解,核心算法采用Hierholzer算法,配合欧拉路径的存在性判定,帮助你深入理解图论中这一经典问题的工程实现。
核心概念:欧拉路径与欧拉回路
在深入代码之前,我们需要明确几个关键概念:
欧拉路径(Eulerian Path):经过图中每条边恰好一次的路径。
欧拉回路(Eulerian Circuit):起点与终点相同的欧拉路径,即经过每条边恰好一次后回到出发点的闭合路径。
一笔画问题本质上就是在问:给定一个无向图,它是否存在欧拉路径或欧拉回路?如果存在,具体路径是什么?
存在性判定定理
对于无向图,欧拉路径与回路的存在遵循以下判定条件:
| 条件 | 欧拉回路 | 欧拉路径(非回路) |
|---|---|---|
| 连通性 | 所有非零度顶点连通 | 所有非零度顶点连通 |
| 奇度顶点数 | 0个 | 恰好2个 |
| 起点与终点 | 任意顶点均可 | 两个奇度顶点 |
度(Degree) 指的是与该顶点相连的边的数量。如果一个顶点的度为奇数,则称其为奇度顶点。上述定理告诉我们:只要统计图中奇度顶点的数量,就能快速判定一笔画的可能性。
Hierholzer算法:构造欧拉路径的经典方法
Hierholzer算法是构造欧拉路径最优雅、最高效的算法之一,其核心思想可以概括为“顺藤摸瓜,回溯补环”:
- 从起点出发,沿着未访问的边一路向前,直到无法继续前进(此时必然回到某个已经访问过的顶点)。
- 将这条路径记录下来。
- 回溯到路径上还有未访问边相连的第一个顶点,从该顶点继续”顺藤摸瓜”。
- 将新发现的路径环插入到原路径的对应位置。
- 重复上述过程,直到所有边都被访问。
算法之所以高效,是因为每条边只被访问一次,时间复杂度为 O(E),其中 E 是边的数量。
Java实现:完整项目结构
下面是完整的Java实现,包含图结构定义、欧拉条件判定、Hierholzer路径构造以及一个可运行的示例。
import java.util.*;
/**
* 一笔画(欧拉路径/回路)求解器
* 使用Hierholzer算法实现
*/
public class EulerianPathSolver {
/**
* 图类:使用邻接表存储无向图
*/
static class Graph {
// 顶点数量
private final int vertices;
// 邻接表:每个顶点对应的邻接顶点列表
private final List<Integer>[] adjacencyList;
// 记录边的访问状态,用于Hierholzer算法
// edges[u] 存储与顶点u相连的邻接顶点,遍历时移除已访问的边
private final List<Integer>[] edges;
@SuppressWarnings("unchecked")
public Graph(int vertices) {
this.vertices = vertices;
this.adjacencyList = new ArrayList[vertices];
this.edges = new ArrayList[vertices];
for (int i = 0; i < vertices; i++) {
adjacencyList[i] = new ArrayList<>();
edges[i] = new ArrayList<>();
}
}
/**
* 添加无向边
* @param u 顶点u
* @param v 顶点v
*/
public void addEdge(int u, int v) {
adjacencyList[u].add(v);
adjacencyList[v].add(u);
edges[u].add(v);
edges[v].add(u);
}
/**
* 获取顶点数量
*/
public int getVertices() {
return vertices;
}
/**
* 获取邻接表(用于判定连通性)
*/
public List<Integer>[] getAdjacencyList() {
return adjacencyList;
}
/**
* 获取可修改的边列表(用于Hierholzer算法)
*/
public List<Integer>[] getEdges() {
return edges;
}
/**
* 获取指定顶点的度
*/
public int getDegree(int v) {
return adjacencyList[v].size();
}
}
/**
* 欧拉路径结果枚举
*/
enum EulerianType {
EULERIAN_CIRCUIT, // 欧拉回路(所有顶点度数为偶数)
EULERIAN_PATH, // 欧拉路径(恰好两个顶点度数为奇数)
NOT_EULERIAN // 不存在欧拉路径
}
/**
* 判定图的欧拉类型并返回起点
* @param graph 输入图
* @return 包含欧拉类型和起点的结果
*/
public static Result analyzeGraph(Graph graph) {
int vertices = graph.getVertices();
int oddDegreeCount = 0;
int startVertex = 0;
// 统计奇度顶点的数量
for (int i = 0; i < vertices; i++) {
int degree = graph.getDegree(i);
if (degree % 2 != 0) {
oddDegreeCount++;
startVertex = i; // 记录最后一个奇度顶点作为起点
}
}
// 判断欧拉类型
EulerianType type;
if (oddDegreeCount == 0) {
type = EulerianType.EULERIAN_CIRCUIT;
// 回路可以从任意非零度顶点开始
for (int i = 0; i < vertices; i++) {
if (graph.getDegree(i) > 0) {
startVertex = i;
break;
}
}
} else if (oddDegreeCount == 2) {
type = EulerianType.EULERIAN_PATH;
// 路径必须从奇度顶点开始
for (int i = 0; i < vertices; i++) {
if (graph.getDegree(i) % 2 != 0) {
startVertex = i;
break;
}
}
} else {
type = EulerianType.NOT_EULERIAN;
}
// 额外检查:非零度顶点是否连通
if (type != EulerianType.NOT_EULERIAN && !isConnected(graph)) {
type = EulerianType.NOT_EULERIAN;
}
return new Result(type, startVertex);
}
/**
* 使用DFS检查非零度顶点是否连通
*/
private static boolean isConnected(Graph graph) {
int vertices = graph.getVertices();
boolean[] visited = new boolean[vertices];
// 找到一个非零度顶点作为DFS起点
int start = -1;
for (int i = 0; i < vertices; i++) {
if (graph.getDegree(i) > 0) {
start = i;
break;
}
}
// 如果没有边,认为连通(空图)
if (start == -1) return true;
// 从start开始DFS
dfs(graph.getAdjacencyList(), start, visited);
// 检查所有非零度顶点是否都被访问
for (int i = 0; i < vertices; i++) {
if (graph.getDegree(i) > 0 && !visited[i]) {
return false;
}
}
return true;
}
private static void dfs(List<Integer>[] adj, int v, boolean[] visited) {
visited[v] = true;
for (int neighbor : adj[v]) {
if (!visited[neighbor]) {
dfs(adj, neighbor, visited);
}
}
}
/**
* Hierholzer算法:构造欧拉路径/回路
* @param graph 输入图
* @param startVertex 起始顶点
* @return 欧拉路径(顶点序列)
*/
public static List<Integer> findEulerianPath(Graph graph, int startVertex) {
List<Integer>[] edges = graph.getEdges();
LinkedList<Integer> path = new LinkedList<>();
Stack<Integer> stack = new Stack<>();
stack.push(startVertex);
while (!stack.isEmpty()) {
int current = stack.peek();
// 如果当前顶点还有未访问的边
if (!edges[current].isEmpty()) {
// 取出一条未访问的边
int next = edges[current].remove(edges[current].size() - 1);
// 在邻接表中移除反向边(无向图需要双向移除)
edges[next].remove((Integer) current);
stack.push(next);
} else {
// 当前顶点没有未访问的边,加入路径
path.addFirst(stack.pop());
}
}
return path;
}
/**
* 分析结果封装类
*/
static class Result {
public final EulerianType type;
public final int startVertex;
public Result(EulerianType type, int startVertex) {
this.type = type;
this.startVertex = startVertex;
}
}
// ==================== 示例与测试 ====================
public static void main(String[] args) {
System.out.println("===== 示例1:哥尼斯堡七桥问题(不可一笔画) =====");
// 模拟哥尼斯堡七桥:4块陆地,7座桥
// 顶点0,1,2,3 分别代表四块陆地
Graph konigsberg = new Graph(4);
konigsberg.addEdge(0, 1); // 桥1
konigsberg.addEdge(0, 1); // 桥2(两块陆地间有两座桥)
konigsberg.addEdge(0, 2); // 桥3
konigsberg.addEdge(0, 3); // 桥4
konigsberg.addEdge(1, 2); // 桥5
konigsberg.addEdge(1, 3); // 桥6
konigsberg.addEdge(2, 3); // 桥7
solveAndPrint(konigsberg);
System.out.println("\n===== 示例2:欧拉回路(所有顶点度数为偶数) =====");
// 一个正方形加一条对角线,构成欧拉回路
Graph circuit = new Graph(4);
circuit.addEdge(0, 1);
circuit.addEdge(1, 2);
circuit.addEdge(2, 3);
circuit.addEdge(3, 0);
circuit.addEdge(0, 2); // 对角线
circuit.addEdge(1, 3); // 对角线
solveAndPrint(circuit);
System.out.println("\n===== 示例3:欧拉路径(两个奇度顶点) =====");
// 两个三角形共享一条边
Graph path = new Graph(4);
path.addEdge(0, 1);
path.addEdge(1, 2);
path.addEdge(2, 0); // 第一个三角形
path.addEdge(1, 2); // 共享边(第二次添加)
path.addEdge(2, 3);
path.addEdge(3, 1); // 第二个三角形
solveAndPrint(path);
System.out.println("\n===== 示例4:经典"日"字形一笔画 =====");
// 日字形图:可以一笔画
Graph ri = new Graph(6);
ri.addEdge(0, 1);
ri.addEdge(1, 2);
ri.addEdge(2, 3);
ri.addEdge(3, 0); // 外框
ri.addEdge(1, 4);
ri.addEdge(4, 5);
ri.addEdge(5, 2); // 中间一竖
solveAndPrint(ri);
}
/**
* 求解并打印结果
*/
private static void solveAndPrint(Graph graph) {
Result result = analyzeGraph(graph);
System.out.println("判定结果: " + result.type);
if (result.type == EulerianType.NOT_EULERIAN) {
System.out.println("该图无法一笔画成。");
return;
}
System.out.println("起始顶点: " + result.startVertex);
List<Integer> path = findEulerianPath(graph, result.startVertex);
System.out.println("欧拉路径/回路: " + path);
// 验证路径覆盖了所有边
int totalEdges = 0;
for (int i = 0; i < graph.getVertices(); i++) {
totalEdges += graph.getAdjacencyList()[i].size();
}
totalEdges /= 2; // 无向图每条边被计算两次
System.out.println("边数: " + totalEdges + ", 路径长度: " + (path.size() - 1));
System.out.println("验证: " + (path.size() - 1 == totalEdges ? "通过 ✓" : "失败 ✗"));
}
}
代码详解
图结构设计
代码中采用邻接表存储无向图。为了支持Hierholzer算法中”移除已访问边”的操作,我们维护了两套数据结构:
adjacencyList:原始邻接表,用于连通性判定和计算顶点度数。edges:可修改的边列表,算法执行过程中会动态移除已访问的边。
这种设计保证了原始图结构不被破坏,同时满足算法对边移除的需求。
欧拉类型判定
analyzeGraph 方法实现了核心判定逻辑:
- 遍历所有顶点,统计奇度顶点数量。
- 根据奇度顶点数量判定欧拉类型:
- 0个奇度顶点 → 欧拉回路
- 2个奇度顶点 → 欧拉路径
- 其他数量 → 无法一笔画
- 额外执行连通性检查,确保所有非零度顶点属于同一个连通分量。
Hierholzer算法实现
findEulerianPath 是算法的核心实现。这里采用栈 + 链表的经典组合:
- 栈(Stack):模拟深度优先遍历的过程,记录当前探索路径上的顶点。
- 链表(LinkedList):使用
addFirst在头部插入顶点,保证路径顺序正确。
算法执行时,只要当前顶点还有未访问的边,就沿着边继续深入;当当前顶点的所有边都被访问后,将该顶点从栈中弹出并加入路径的头部。由于顶点是在回溯时才加入路径,使用 addFirst 确保了最终路径的正向顺序。
边的双向移除
无向图中每条边连接两个顶点,当从一个顶点走向另一个顶点时,需要将两个方向上的边同时标记为已访问。代码中通过 edges[next].remove((Integer) current) 实现反向边的移除,这里的类型转换 (Integer) 确保调用的是按对象移除的方法,而非按索引移除。
运行结果解析
运行上述代码,你将看到类似如下的输出:
===== 示例1:哥尼斯堡七桥问题(不可一笔画) =====
判定结果: NOT_EULERIAN
该图无法一笔画成。
===== 示例2:欧拉回路(所有顶点度数为偶数) =====
判定结果: EULERIAN_CIRCUIT
起始顶点: 0
欧拉路径/回路: [0, 1, 2, 0, 3, 1, 3, 2]
边数: 6, 路径长度: 6
验证: 通过 ✓
===== 示例3:欧拉路径(两个奇度顶点) =====
判定结果: EULERIAN_PATH
起始顶点: 0
欧拉路径/回路: [0, 2, 3, 1, 2, 1, 0]
边数: 6, 路径长度: 6
验证: 通过 ✓
示例1成功复现了欧拉的历史结论:哥尼斯堡七桥不存在欧拉路径,因为四个陆地的度数分别为3、3、3、3,奇度顶点数量为4,不符合判定条件。
复杂度分析
| 步骤 | 时间复杂度 | 空间复杂度 | 说明 |
|---|---|---|---|
| 度数统计 | O(V) | O(1) | 遍历所有顶点 |
| 连通性判定 | O(V + E) | O(V) | DFS遍历 |
| Hierholzer算法 | O(E) | O(V + E) | 每条边只访问一次 |
| 总计 | O(V + E) | O(V + E) | 线性复杂度 |
Hierholzer算法的时间复杂度是 O(E),这是因为它保证每条边仅被访问一次。对于稀疏图(边数接近顶点数),算法效率极高。
扩展与变体
有向图的欧拉路径
有向图的判定条件略有不同:
– 欧拉回路:所有顶点入度等于出度,且底层无向图连通。
– 欧拉路径:恰好一个顶点出度 = 入度 + 1(起点),恰好一个顶点入度 = 出度 + 1(终点),其余顶点入度等于出度。
Hierholzer算法同样适用于有向图,只需将边视为单向,无需双向移除。
弗罗莱算法(Fleury’s Algorithm)
弗罗莱算法是另一种构造欧拉路径的方法,其核心规则是:不走桥(割边),除非别无选择。虽然思想直观,但每步需要判断桥,时间复杂度为 O(E × (V + E)),远不如Hierholzer算法高效,因此工程中很少使用。
实际应用场景
欧拉路径算法在现实生活中有广泛应用:
– DNA序列拼接:将基因片段重叠图化,寻找欧拉路径以重建完整序列。
– 路线规划:邮递员问题(中国邮路问题)的核心思想与欧拉路径密切相关。
– 电路板检测:检测PCB上的走线是否能一笔画完成。
– 游戏关卡设计:验证迷宫或关卡地图中路径设计的合理性。
总结
一笔画问题虽然简单直观,却蕴含着深刻的图论思想。通过本文,你掌握了:
- 欧拉路径与欧拉回路的数学判定条件
- Hierholzer算法的核心思想与栈实现技巧
- 完整的Java工程代码,包含图的构建、条件判定、路径构造和结果验证
- 算法的复杂度分析与实际应用场景
Hierholzer算法以其线性时间复杂度成为求解欧拉路径的最优选择。理解这一算法不仅能帮助你解决一笔画游戏,更能为后续学习更复杂的图算法打下坚实基础。