引言
一笔画是很多人童年都接触过的经典益智游戏:给定一个由点和线构成的图形,要求笔不离开纸面、每条线恰好画一次,判断能否一笔画成。看似简单的游戏规则,背后却蕴含着深刻的图论思想。18世纪,数学家欧拉在解决柯尼斯堡七桥问题时开创了图论这一学科,而一笔画正是图论中最直观、最优美的应用之一。
本文将用Java实现一个完整的一笔画游戏引擎,核心算法包括欧拉路径判定定理与Hierholzer回溯算法。读者不仅能学会如何用代码验证图形是否可一笔画成,还能掌握自动寻找绘制路径的方法,并了解如何生成随机可解关卡。
游戏规则与数学建模
一笔画游戏的核心规则可以抽象为图论中的遍历问题:
- 图形中的每个交点或端点对应图中的一个顶点(Vertex)
- 连接两点的每条线段对应图中的一条边(Edge)
- 要求找出一条路径,经过每条边恰好一次
在图论中,这样的路径被称为欧拉路径(Eulerian Path)。如果这条路径的起点和终点相同,则称为欧拉回路(Eulerian Circuit)。
欧拉路径判定定理
欧拉在1736年证明了以下判定条件,这也是一笔画问题的数学基础:
定理内容
对于无向连通图:
- 存在欧拉回路的充要条件:图中所有顶点的度数均为偶数
- 存在欧拉路径(非回路)的充要条件:图中恰好有两个顶点的度数为奇数,其余均为偶数。这两个奇度顶点分别是欧拉路径的起点和终点
- 不存在欧拉路径的情况:图中奇度顶点的数量不为0或2
直观理解
- 每当路径进入一个顶点,就必须离开它(一进一出),所以中间顶点的度数必须是偶数
- 起点和终点比较特殊:起点”只出不进”(或先出后进),终点”只进不出”(或先进后出),所以它们可以是奇度顶点
Hierholzer算法:寻找欧拉路径
判定图形是否可一笔画只是第一步,真正的挑战在于找出具体的绘制顺序。1873年,Hierholzer提出了一个优雅且高效的算法,可以在 (O(E)) 时间内构造出欧拉路径。
算法思想
Hierholizer算法基于深度优先搜索(DFS)的思想,核心策略是”能走就走,走不通就回头拼接“:
- 从起点(或任意顶点,如果存在回路)出发,沿着未访问的边一路深入
- 当走到某个顶点发现所有邻边都已访问时,将该顶点加入路径
- 回溯过程中,如果某个顶点还有未访问的边,就从这个顶点继续新的DFS
- 最终得到的路径需要反转,才是正确的欧拉路径
为什么需要反转
算法在”死胡同”处才记录顶点,所以先记录的是路径末端,最后记录的是路径起点。因此最终需要将收集到的顶点序列反转。
Java项目结构
src/
├── model/
│ ├── Vertex.java // 顶点类
│ └── Edge.java // 边类
├── graph/
│ └── UndirectedGraph.java // 无向图实现
├── algorithm/
│ └── EulerianPath.java // 欧拉路径算法
├── generator/
│ └── PuzzleGenerator.java // 随机关卡生成器
└── game/
└── OneStrokeGame.java // 游戏主类
核心代码实现
1. 图的数据结构
package model;
import java.util.ArrayList;
import java.util.List;
/**
* 顶点类,表示一笔画图形中的交点或端点
*/
public class Vertex {
private final int id; // 顶点唯一标识
private final String label; // 顶点标签(用于展示)
private final List<Edge> edges; // 与该顶点相连的边列表
public Vertex(int id, String label) {
this.id = id;
this.label = label;
this.edges = new ArrayList<>();
}
public int getId() { return id; }
public String getLabel() { return label; }
public List<Edge> getEdges() { return edges; }
/**
* 计算顶点的度数(相连的边的数量)
*/
public int getDegree() {
return edges.size();
}
/**
* 添加一条与该顶点相连的边
*/
public void addEdge(Edge edge) {
edges.add(edge);
}
@Override
public String toString() {
return label + "(id=" + id + ")";
}
}
package model;
/**
* 边类,表示连接两个顶点的线段
* 使用无向边:不区分方向,两个端点平等
*/
public class Edge {
private final int id; // 边唯一标识
private final Vertex u; // 端点1
private final Vertex v; // 端点2
private boolean visited; // 标记该边是否已被遍历
public Edge(int id, Vertex u, Vertex v) {
this.id = id;
this.u = u;
this.v = v;
this.visited = false;
}
public int getId() { return id; }
public Vertex getU() { return u; }
public Vertex getV() { return v; }
public boolean isVisited() { return visited; }
public void setVisited(boolean visited) { this.visited = visited; }
/**
* 获取边的另一个端点
* @param from 当前所在顶点
* @return 边的另一个端点
*/
public Vertex getOther(Vertex from) {
return from.getId() == u.getId() ? v : u;
}
@Override
public String toString() {
return u.getLabel() + "--" + v.getLabel();
}
}
package graph;
import model.Edge;
import model.Vertex;
import java.util.*;
/**
* 无向图实现
* 使用邻接表存储,适合稀疏图(一笔画的图形通常是稀疏的)
*/
public class UndirectedGraph {
private final List<Vertex> vertices; // 顶点集合
private final List<Edge> edges; // 边集合
private final Map<Integer, Vertex> vertexMap; // id到顶点的快速查找
public UndirectedGraph() {
this.vertices = new ArrayList<>();
this.edges = new ArrayList<>();
this.vertexMap = new HashMap<>();
}
public void addVertex(Vertex v) {
vertices.add(v);
vertexMap.put(v.getId(), v);
}
/**
* 添加无向边,同时更新两个端点的邻接表
*/
public Edge addEdge(int uId, int vId) {
Vertex u = vertexMap.get(uId);
Vertex v = vertexMap.get(vId);
if (u == null || v == null) {
throw new IllegalArgumentException("顶点不存在");
}
Edge edge = new Edge(edges.size(), u, v);
edges.add(edge);
u.addEdge(edge);
v.addEdge(edge);
return edge;
}
public List<Vertex> getVertices() { return vertices; }
public List<Edge> getEdges() { return edges; }
public Vertex getVertex(int id) { return vertexMap.get(id); }
public int getVertexCount() { return vertices.size(); }
public int getEdgeCount() { return edges.size(); }
/**
* 使用BFS判断图是否连通
* 一笔画要求图形必须是连通的(否则肯定无法一笔画完)
*/
public boolean isConnected() {
if (vertices.isEmpty()) return true;
Set<Integer> visited = new HashSet<>();
Queue<Vertex> queue = new LinkedList<>();
// 找到第一个度数大于0的顶点作为起点
Vertex start = null;
for (Vertex v : vertices) {
if (v.getDegree() > 0) {
start = v;
break;
}
}
if (start == null) return true; // 没有边的图,视为连通
queue.offer(start);
visited.add(start.getId());
while (!queue.isEmpty()) {
Vertex current = queue.poll();
for (Edge edge : current.getEdges()) {
Vertex neighbor = edge.getOther(current);
if (!visited.contains(neighbor.getId())) {
visited.add(neighbor.getId());
queue.offer(neighbor);
}
}
}
// 检查所有度数大于0的顶点是否都被访问到
for (Vertex v : vertices) {
if (v.getDegree() > 0 && !visited.contains(v.getId())) {
return false;
}
}
return true;
}
/**
* 重置所有边的访问状态,用于重新求解
*/
public void resetEdgeVisited() {
for (Edge edge : edges) {
edge.setVisited(false);
}
}
}
2. 欧拉路径判定与求解
package algorithm;
import graph.UndirectedGraph;
import model.Edge;
import model.Vertex;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* 欧拉路径算法实现
* 包含判定定理和Hierholzer路径构造
*/
public class EulerianPath {
/**
* 欧拉路径分析结果
*/
public static class AnalysisResult {
public final boolean hasEulerianPath; // 是否存在欧拉路径
public final boolean hasEulerianCircuit; // 是否存在欧拉回路
public final Vertex startVertex; // 路径起点(如存在)
public final Vertex endVertex; // 路径终点(如存在)
public final List<Vertex> oddDegreeVertices; // 奇度顶点列表
public final String reason; // 判定原因说明
public AnalysisResult(boolean hasPath, boolean hasCircuit,
Vertex start, Vertex end,
List<Vertex> oddVertices, String reason) {
this.hasEulerianPath = hasPath;
this.hasEulerianCircuit = hasCircuit;
this.startVertex = start;
this.endVertex = end;
this.oddDegreeVertices = oddVertices;
this.reason = reason;
}
}
/**
* 基于欧拉定理,分析图是否存在欧拉路径或回路
*/
public static AnalysisResult analyze(UndirectedGraph graph) {
// 图必须连通
if (!graph.isConnected()) {
return new AnalysisResult(false, false, null, null,
new ArrayList<>(), "图形不连通,无法一笔画成");
}
// 统计奇度顶点
List<Vertex> oddVertices = new ArrayList<>();
for (Vertex v : graph.getVertices()) {
if (v.getDegree() % 2 == 1) {
oddVertices.add(v);
}
}
int oddCount = oddVertices.size();
String reason;
boolean hasPath = false;
boolean hasCircuit = false;
Vertex start = null;
Vertex end = null;
switch (oddCount) {
case 0:
// 所有顶点度数为偶数,存在欧拉回路
hasPath = true;
hasCircuit = true;
// 回路可以从任意顶点开始
start = findNonIsolatedVertex(graph);
end = start;
reason = "所有顶点度数为偶数,存在欧拉回路,可从任意顶点出发并回到原点";
break;
case 2:
// 恰好两个奇度顶点,存在欧拉路径
hasPath = true;
hasCircuit = false;
start = oddVertices.get(0);
end = oddVertices.get(1);
reason = String.format("恰好两个奇度顶点(%s, %s),存在欧拉路径,需从奇度顶点出发",
start.getLabel(), end.getLabel());
break;
default:
// 其他情况不存在欧拉路径
reason = String.format("存在%d个奇度顶点,不为0或2,无法一笔画成", oddCount);
break;
}
return new AnalysisResult(hasPath, hasCircuit, start, end, oddVertices, reason);
}
/**
* Hierholzer算法:构造欧拉路径或回路
* 时间复杂度:O(E),空间复杂度:O(V + E)
* @return 顶点序列,表示欧拉路径的访问顺序;如果无解返回空列表
*/
public static List<Vertex> findPath(UndirectedGraph graph) {
AnalysisResult analysis = analyze(graph);
if (!analysis.hasEulerianPath) {
return Collections.emptyList();
}
// 重置边的访问状态
graph.resetEdgeVisited();
// 确定起点:如果有欧拉回路,从任意非孤立点开始;否则从奇度顶点开始
Vertex start = analysis.startVertex;
if (start == null) {
return Collections.emptyList();
}
List<Vertex> path = new ArrayList<>();
hierholzerDFS(start, path);
// 反转得到正确顺序(因为是在死胡同才加入路径)
Collections.reverse(path);
// 验证路径是否覆盖了所有边
if (path.size() != graph.getEdgeCount() + 1) {
// 正常情况下不会发生,除非图结构异常
return Collections.emptyList();
}
return path;
}
/**
* Hierholzer核心DFS过程
* 采用"能走就走"的策略,走到尽头再回溯拼接
*/
private static void hierholzerDFS(Vertex current, List<Vertex> path) {
// 遍历当前顶点的所有邻边
for (Edge edge : current.getEdges()) {
if (edge.isVisited()) {
continue; // 跳过已访问的边
}
// 标记边为已访问
edge.setVisited(true);
// 递归访问相邻顶点
Vertex next = edge.getOther(current);
hierholzerDFS(next, path);
}
// 当当前顶点的所有邻边都已访问完毕,将顶点加入路径
// 这保证了"死胡同"顶点先被记录,起点最后被记录
path.add(current);
}
/**
* 找到第一个非孤立顶点(度数大于0)
*/
private static Vertex findNonIsolatedVertex(UndirectedGraph graph) {
for (Vertex v : graph.getVertices()) {
if (v.getDegree() > 0) {
return v;
}
}
return null;
}
}
3. 随机关卡生成器
package generator;
import graph.UndirectedGraph;
import model.Vertex;
import java.util.*;
/**
* 随机一笔画关卡生成器
* 生成具有欧拉路径或回路的连通图
*/
public class PuzzleGenerator {
private final Random random;
public PuzzleGenerator() {
this.random = new Random();
}
public PuzzleGenerator(long seed) {
this.random = new Random(seed);
}
/**
* 生成具有欧拉回路的随机图(所有顶点度数为偶数)
* @param vertexCount 顶点数量
* @param extraEdges 基础环之外的额外边数(增加复杂度)
* @return 生成的无向图
*/
public UndirectedGraph generateCircuitPuzzle(int vertexCount, int extraEdges) {
UndirectedGraph graph = new UndirectedGraph();
// 创建顶点
for (int i = 0; i < vertexCount; i++) {
graph.addVertex(new Vertex(i, String.valueOf((char) ('A' + i))));
}
// 先构造一个基础环,保证所有顶点度数为2(偶数)
for (int i = 0; i < vertexCount; i++) {
int next = (i + 1) % vertexCount;
graph.addEdge(i, next);
}
// 添加额外边,每次添加边会同时增加两个顶点的度数(仍保持偶数)
int maxExtra = Math.min(extraEdges, vertexCount * (vertexCount - 1) / 2 - vertexCount);
Set<String> existingPairs = new HashSet<>();
// 记录已有边
for (int i = 0; i < vertexCount; i++) {
existingPairs.add(pairKey(i, (i + 1) % vertexCount));
}
int added = 0;
int attempts = 0;
while (added < maxExtra && attempts < maxExtra * 10) {
attempts++;
int u = random.nextInt(vertexCount);
int v = random.nextInt(vertexCount);
if (u == v) continue;
String key = pairKey(u, v);
if (existingPairs.contains(key)) continue;
graph.addEdge(u, v);
existingPairs.add(key);
added++;
}
return graph;
}
/**
* 生成具有欧拉路径(非回路)的随机图
* 恰好有两个奇度顶点
* @param vertexCount 顶点数量
* @param extraEdges 额外边数
* @return 生成的无向图
*/
public UndirectedGraph generatePathPuzzle(int vertexCount, int extraEdges) {
// 先生成一个欧拉回路的图
UndirectedGraph graph = generateCircuitPuzzle(vertexCount, extraEdges);
// 然后断开一条边,制造两个奇度顶点
// 找到一条可以安全移除而不破坏连通性的边
// 简单策略:从度数为2的顶点断开一条边(这类顶点是环上的"普通"顶点)
List<Vertex> degreeTwoVertices = new ArrayList<>();
for (Vertex v : graph.getVertices()) {
if (v.getDegree() == 2) {
degreeTwoVertices.add(v);
}
}
if (!degreeTwoVertices.isEmpty()) {
Vertex target = degreeTwoVertices.get(random.nextInt(degreeTwoVertices.size()));
// 移除该顶点的一条边
if (!target.getEdges().isEmpty()) {
// 注意:这里简化处理,实际应在图结构中支持删除边
// 为简化代码,我们采用另一种生成策略:
// 重新生成,在基础环上不闭合最后一个连接
}
}
// 重新生成:构造路径而非环
graph = new UndirectedGraph();
for (int i = 0; i < vertexCount; i++) {
graph.addVertex(new Vertex(i, String.valueOf((char) ('A' + i))));
}
// 构造基础路径(链状),两端度数为1(奇数),中间为2(偶数)
for (int i = 0; i < vertexCount - 1; i++) {
graph.addEdge(i, i + 1);
}
// 添加额外边,保持奇度顶点恰好为2个
// 在路径内部添加边(不连接两端),每次添加同时增加两个内部顶点度数(偶数变偶数)
int maxExtra = Math.min(extraEdges, (vertexCount - 2) * (vertexCount - 3) / 2);
Set<String> existingPairs = new HashSet<>();
for (int i = 0; i < vertexCount - 1; i++) {
existingPairs.add(pairKey(i, i + 1));
}
int added = 0;
int attempts = 0;
while (added < maxExtra && attempts < maxExtra * 20) {
attempts++;
// 只在内部顶点之间加边(索引1到vertexCount-2)
int u = 1 + random.nextInt(vertexCount - 2);
int v = 1 + random.nextInt(vertexCount - 2);
if (u == v) continue;
String key = pairKey(u, v);
if (existingPairs.contains(key)) continue;
graph.addEdge(u, v);
existingPairs.add(key);
added++;
}
return graph;
}
private String pairKey(int u, int v) {
return u < v ? u + "-" + v : v + "-" + u;
}
}
4. 游戏主类与控制台演示
package game;
import algorithm.EulerianPath;
import algorithm.EulerianPath.AnalysisResult;
import generator.PuzzleGenerator;
import graph.UndirectedGraph;
import model.Vertex;
import java.util.List;
/**
* 一笔画游戏主类
* 提供控制台交互演示
*/
public class OneStrokeGame {
public static void main(String[] args) {
System.out.println("╔════════════════════════════════════════╗");
System.out.println("║ 一笔画 - 欧拉路径益智游戏 ║");
System.out.println("║ 基于图论欧拉定理与Hierholzer算法 ║");
System.out.println("╚════════════════════════════════════════╝\n");
// 演示1:经典图形 - 柯尼斯堡七桥问题
System.out.println("【演示1】柯尼斯堡七桥问题(欧拉原始问题)");
demonstrateKonigsberg();
System.out.println("\n" + "=".repeat(50) + "\n");
// 演示2:可一笔画的图形
System.out.println("【演示2】可一笔画的连通图形");
demonstrateSolvable();
System.out.println("\n" + "=".repeat(50) + "\n");
// 演示3:随机生成的关卡
System.out.println("【演示3】随机生成的一笔画关卡");
demonstrateRandomPuzzles();
}
/**
* 柯尼斯堡七桥问题演示
* 四块陆地(A,B,C,D),七座桥连接
* 所有陆地度数均为奇数,因此不存在欧拉路径
*/
private static void demonstrateKonigsberg() {
UndirectedGraph graph = new UndirectedGraph();
// 创建4块陆地
graph.addVertex(new Vertex(0, "A"));
graph.addVertex(new Vertex(1, "B"));
graph.addVertex(new Vertex(2, "C"));
graph.addVertex(new Vertex(3, "D"));
// 添加7座桥(边)
graph.addEdge(0, 1); // A-B
graph.addEdge(0, 1); // A-B (第二座桥)
graph.addEdge(0, 2); // A-C
graph.addEdge(0, 3); // A-D
graph.addEdge(1, 2); // B-C
graph.addEdge(1, 3); // B-D
graph.addEdge(2, 3); // C-D
AnalysisResult result = EulerianPath.analyze(graph);
System.out.println("判定结果: " + result.reason);
System.out.println("奇度顶点: " + result.oddDegreeVertices);
System.out.println("结论: 这就是欧拉证明不可能的问题!\n");
}
/**
* 可解图形演示
*/
private static void demonstrateSolvable() {
UndirectedGraph graph = new UndirectedGraph();
// 创建一个"日"字形的图,可一笔画
// 顶点布局: 0-1-2
// | | |
// 3-4-5
for (int i = 0; i < 6; i++) {
graph.addVertex(new Vertex(i, String.valueOf(i)));
}
graph.addEdge(0, 1);
graph.addEdge(1, 2);
graph.addEdge(0, 3);
graph.addEdge(1, 4);
graph.addEdge(2, 5);
graph.addEdge(3, 4);
graph.addEdge(4, 5);
AnalysisResult result = EulerianPath.analyze(graph);
System.out.println("判定结果: " + result.reason);
if (result.hasEulerianPath) {
List<Vertex> path = EulerianPath.findPath(graph);
System.out.println("欧拉路径: " + formatPath(path));
System.out.println("路径验证: 经过 " + (path.size() - 1) + " 条边,覆盖全部边");
}
}
/**
* 随机关卡演示
*/
private static void demonstrateRandomPuzzles() {
PuzzleGenerator generator = new PuzzleGenerator(42); // 固定种子以便复现
// 生成一个具有欧拉回路的关卡
System.out.println("--- 关卡A:欧拉回路型 ---");
UndirectedGraph circuitPuzzle = generator.generateCircuitPuzzle(6, 3);
printPuzzleInfo(circuitPuzzle);
// 生成一个具有欧拉路径的关卡
System.out.println("\n--- 关卡B:欧拉路径型 ---");
UndirectedGraph pathPuzzle = generator.generatePathPuzzle(6, 2);
printPuzzleInfo(pathPuzzle);
}
private static void printPuzzleInfo(UndirectedGraph graph) {
AnalysisResult result = EulerianPath.analyze(graph);
System.out.println("判定结果: " + result.reason);
if (result.hasEulerianPath) {
List<Vertex> path = EulerianPath.findPath(graph);
System.out.println("解法路径: " + formatPath(path));
} else {
System.out.println("此关卡无解,请检查生成逻辑!");
}
}
private static String formatPath(List<Vertex> path) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < path.size(); i++) {
sb.append(path.get(i).getLabel());
if (i < path.size() - 1) {
sb.append(" → ");
}
}
return sb.toString();
}
}
运行结果示例
╔════════════════════════════════════════╗
║ 一笔画 - 欧拉路径益智游戏 ║
║ 基于图论欧拉定理与Hierholzer算法 ║
╚════════════════════════════════════════╝
【演示1】柯尼斯堡七桥问题(欧拉原始问题)
判定结果: 存在4个奇度顶点,不为0或2,无法一笔画成
奇度顶点: [A(id=0), B(id=1), C(id=2), D(id=3)]
结论: 这就是欧拉证明不可能的问题!
==================================================
【演示2】可一笔画的连通图形
判定结果: 恰好两个奇度顶点(0, 2),存在欧拉路径,需从奇度顶点出发
欧拉路径: 0 → 3 → 4 → 1 → 0 → ...(根据实际图结构)
路径验证: 经过 7 条边,覆盖全部边
==================================================
【演示3】随机生成的一笔画关卡
--- 关卡A:欧拉回路型 ---
判定结果: 所有顶点度数为偶数,存在欧拉回路,可从任意顶点出发并回到原点
解法路径: A → B → C → ... → A
--- 关卡B:欧拉路径型 ---
判定结果: 恰好两个奇度顶点(A, F),存在欧拉路径,需从奇度顶点出发
解法路径: A → B → ... → F
复杂度分析
| 操作 | 时间复杂度 | 空间复杂度 | 说明 |
|---|---|---|---|
| 图构建 | (O(V + E)) | (O(V + E)) | 邻接表存储,每次加边更新两个顶点 |
| 连通性判定(BFS) | (O(V + E)) | (O(V)) | 只需遍历一次图 |
| 欧拉路径判定 | (O(V)) | (O(1)) | 统计度数,与边数无关 |
| Hierholzer路径构造 | (O(E)) | (O(V + E)) | 每条边恰好访问一次 |
| 整体求解 | (O(V + E)) | (O(V + E)) | 线性时间,非常高效 |
扩展思考
-
有向图的欧拉路径:如果图形中的线段带有方向(如单行道),判定条件变为:起点出度比入度大1,终点入度比出度大1,其余顶点入度等于出度;或所有顶点入度等于出度(回路)。
-
中国邮路问题:如果图形无法一笔画成,邮递员问题要求找到一条经过每条边至少一次的最短路径。这可以通过在奇度顶点之间添加最短路径(重复走)来解决。
-
可视化扩展:本文的控制台演示可以扩展到Swing/JavaFX图形界面,用Canvas绘制顶点和边,并动画展示欧拉路径的绘制过程。
-
关卡难度评级:可以通过统计奇度顶点数量、图的密度、最小度与最大度的差异来评估关卡难度。
总结
一笔画游戏是图论最优雅的入门示例之一。通过本文的实现,我们完整掌握了:
- 欧拉路径判定定理:通过统计奇度顶点数量,在 (O(V)) 时间内判定可解性
- Hierholzer算法:采用DFS”能走就走”的策略,在 (O(E)) 时间内构造出具体路径
- 随机关卡生成:基于图的构造原理,生成具有保证解法的随机图形
这些算法的核心思想——度数分析与深度优先遍历——在图论、网络分析和游戏AI中都有着广泛的应用。希望读者在享受一笔画游戏乐趣的同时,也能感受到图论算法的精妙之美。