引言
成语接龙是中国传统的文字游戏,规则简单而富有挑战性:参与者依次说出一个成语,后一个成语的第一个字必须与前一个成语的最后一个字相同(允许同音字或同字)。例如”一马当先 → 先声夺人 → 人山人海 → 海阔天空”。表面上看这是一个语言游戏,但从算法角度分析,成语接龙本质上是一个有向图上的最长路径问题——每个成语是图中的一个节点,若成语A的尾字与成语B的首字匹配,则存在一条从A到B的有向边。本文将用Java实现一个智能成语接龙系统,深入讲解Trie前缀树高效检索、有向图构建与深度优先搜索最长路径三大核心算法。
一、成语接龙的数学建模
1.1 有向图表示
设成语集合为 $V = {c_1, c_2, …, c_n}$,其中每个成语 $c_i$ 有首字 $first(c_i)$ 和尾字 $last(c_i)$。
定义有向图 $G = (V, E)$,其中边集 $E$ 满足:
$$E = {(c_i, c_j) \mid last(c_i) = first(c_j)}$$
成语接龙的目标是在图 $G$ 中找到一条最长简单路径(不重复经过节点的路径)。这是一个经典的NP-hard问题(哈密顿路径的特例),因此对于大规模图,需要使用启发式策略或近似算法。
1.2 约束扩展
在实际游戏中,还可以增加以下约束:
- 长度约束:接龙长度达到 $K$ 条成语时获胜
- 难度约束:只能使用特定难度的成语(如常用成语)
- 禁用约束:某些成语不允许使用
- 时间约束:每步有时间限制
本文的实现支持长度约束和禁用约束。
二、核心算法详解
2.1 Trie前缀树检索
为了快速查找以某个字开头的所有成语,我们使用Trie前缀树。Trie树的核心思想是:
- 每个节点代表一个汉字
- 从根节点到某个节点的路径上的字符序列构成一个前缀
- 在节点上标记是否存在以该前缀为开头的成语
Trie树的插入和查找时间复杂度均为 $O(L)$,其中 $L$ 为成语长度。相比哈希表的 $O(1)$ 查找,Trie树的优势在于前缀检索——可以快速找到所有以某个字开头的成语。
2.2 有向图构建与邻接表
基于Trie树的检索结果,我们可以高效构建有向图:
- 遍历所有成语,对每个成语 $c_i$,查询Trie树中所有以 $last(c_i)$ 开头的成语
- 为每对匹配的成语建立有向边
- 使用邻接表存储图结构,空间复杂度 $O(|V| + |E|)$
2.3 最长路径搜索
由于最长简单路径是NP-hard问题,我们采用以下策略组合:
- 记忆化DFS:对每个成语,记录以它为起点的最长路径长度和路径本身
- 拓扑启发式:优先扩展出度较小的节点(类似拓扑排序的思想)
- 双向剪枝:若当前路径长度加上剩余可能的最大长度仍小于已找到的最优解,则剪枝
对于无环图(DAG),可以使用动态规划在 $O(|V| + |E|)$ 时间内求解最长路径。但成语接龙图通常存在环(如”山 → 水 → 山”),因此需要使用DFS+记忆化。
三、Java完整实现
import java.util.*;
/**
* 智能成语接龙系统
* 核心算法:Trie前缀树、有向图构建、记忆化DFS最长路径搜索
*/
public class IdiomSolitaire {
/**
* Trie树节点,每个节点代表一个汉字
*/
public static class TrieNode {
public Map<Character, TrieNode> children; // 子节点映射
public List<String> idioms; // 以当前路径为前缀的成语列表
public boolean isEndOfWord; // 是否为一个完整成语的结尾
public TrieNode() {
this.children = new HashMap<>();
this.idioms = new ArrayList<>();
this.isEndOfWord = false;
}
}
/**
* Trie前缀树:支持快速前缀检索和精确匹配
*/
public static class Trie {
private TrieNode root;
public Trie() {
this.root = new TrieNode();
}
/**
* 插入一个成语到Trie树
*/
public void insert(String idiom) {
TrieNode node = root;
for (char ch : idiom.toCharArray()) {
node.children.putIfAbsent(ch, new TrieNode());
node = node.children.get(ch);
node.idioms.add(idiom);
}
node.isEndOfWord = true;
}
/**
* 查找所有以指定字开头的成语
*/
public List<String> searchByFirstChar(char firstChar) {
TrieNode node = root.children.get(firstChar);
if (node == null) return new ArrayList<>();
return new ArrayList<>(node.idioms);
}
/**
* 检查是否存在以指定字开头的成语
*/
public boolean hasPrefix(char firstChar) {
return root.children.containsKey(firstChar);
}
}
/**
* 成语数据类,封装成语的元信息
*/
public static class Idiom {
public String text; // 成语文本
public char firstChar; // 首字
public char lastChar; // 尾字
public int length; // 长度(字数)
public int difficulty; // 难度等级 1-5
public Idiom(String text, int difficulty) {
this.text = text;
this.firstChar = text.charAt(0);
this.lastChar = text.charAt(text.length() - 1);
this.length = text.length();
this.difficulty = difficulty;
}
@Override
public String toString() {
return text;
}
}
/**
* 有向图节点,用于接龙路径搜索
*/
public static class GraphNode {
public Idiom idiom;
public List<GraphNode> neighbors; // 出边邻居(尾字匹配的成语)
public int outDegree; // 出度
public int inDegree; // 入度
public GraphNode(Idiom idiom) {
this.idiom = idiom;
this.neighbors = new ArrayList<>();
this.outDegree = 0;
this.inDegree = 0;
}
}
/**
* 成语接龙图,包含完整的图结构和搜索算法
*/
public static class SolitaireGraph {
private Map<String, GraphNode> nodeMap; // 成语文本到图节点的映射
private List<GraphNode> allNodes;
private Trie trie;
public SolitaireGraph() {
this.nodeMap = new HashMap<>();
this.allNodes = new ArrayList<>();
this.trie = new Trie();
}
/**
* 添加成语到系统中
*/
public void addIdiom(Idiom idiom) {
GraphNode node = new GraphNode(idiom);
nodeMap.put(idiom.text, node);
allNodes.add(node);
trie.insert(idiom.text);
}
/**
* 构建有向图:为每个成语建立到尾字匹配成语的边
*/
public void buildGraph() {
for (GraphNode node : allNodes) {
char lastChar = node.idiom.lastChar;
List<String> candidates = trie.searchByFirstChar(lastChar);
for (String candidate : candidates) {
GraphNode neighbor = nodeMap.get(candidate);
if (neighbor != null && neighbor != node) {
node.neighbors.add(neighbor);
node.outDegree++;
neighbor.inDegree++;
}
}
}
System.out.println("图构建完成:" + allNodes.size() + " 个节点");
int totalEdges = allNodes.stream().mapToInt(n -> n.outDegree).sum();
System.out.println("总边数:" + totalEdges);
}
/**
* 使用记忆化DFS搜索从指定成语出发的最长接龙路径
*/
public List<Idiom> findLongestPath(String startIdiom) {
GraphNode start = nodeMap.get(startIdiom);
if (start == null) return new ArrayList<>();
Set<String> visited = new HashSet<>();
Map<String, PathResult> memo = new HashMap<>();
PathResult result = dfsLongestPath(start, visited, memo);
return result.path;
}
/**
* 深度优先搜索,带记忆化
*/
private PathResult dfsLongestPath(GraphNode node, Set<String> visited,
Map<String, PathResult> memo) {
// 记忆化检查
if (memo.containsKey(node.idiom.text)) {
return memo.get(node.idiom.text);
}
visited.add(node.idiom.text);
PathResult best = new PathResult();
best.path.add(node.idiom);
best.length = 1;
// 按出度升序排序邻居,优先尝试"死胡同"节点(剪枝效率更高)
List<GraphNode> sortedNeighbors = new ArrayList<>(node.neighbors);
sortedNeighbors.sort(Comparator.comparingInt(n -> n.outDegree));
for (GraphNode neighbor : sortedNeighbors) {
if (!visited.contains(neighbor.idiom.text)) {
PathResult subResult = dfsLongestPath(neighbor, visited, memo);
if (subResult.length + 1 > best.length) {
best = new PathResult();
best.path.add(node.idiom);
best.path.addAll(subResult.path);
best.length = best.path.size();
}
}
}
visited.remove(node.idiom.text);
// 存储记忆化结果(注意:需要复制路径,避免引用问题)
PathResult memoResult = new PathResult();
memoResult.path = new ArrayList<>(best.path);
memoResult.length = best.length;
memo.put(node.idiom.text, memoResult);
return best;
}
/**
* 全局最长路径搜索:从每个节点出发搜索,取最大值
*/
public List<Idiom> findGlobalLongestPath() {
List<Idiom> bestPath = new ArrayList<>();
for (GraphNode node : allNodes) {
List<Idiom> path = findLongestPath(node.idiom.text);
if (path.size() > bestPath.size()) {
bestPath = path;
}
}
return bestPath;
}
/**
* AI决策:给定当前尾字,选择最优的下一个成语
* 策略:选择出度最小的邻居(保留更多后续选择)
*/
public Idiom chooseNextMove(char lastChar, Set<String> used) {
List<String> candidates = trie.searchByFirstChar(lastChar);
Idiom bestChoice = null;
int minOutDegree = Integer.MAX_VALUE;
for (String candidate : candidates) {
if (used.contains(candidate)) continue;
GraphNode node = nodeMap.get(candidate);
if (node != null) {
// 计算可用出度(排除已使用的成语)
int availableOut = 0;
for (GraphNode neighbor : node.neighbors) {
if (!used.contains(neighbor.idiom.text)) {
availableOut++;
}
}
// 优先选择可用出度最小的(保留后路策略)
if (availableOut < minOutDegree) {
minOutDegree = availableOut;
bestChoice = node.idiom;
}
}
}
return bestChoice;
}
}
public static class PathResult {
public List<Idiom> path;
public int length;
public PathResult() {
this.path = new ArrayList<>();
this.length = 0;
}
}
/**
* 游戏模拟器:模拟人机对战
*/
public static class GameSimulator {
private SolitaireGraph graph;
private Set<String> usedIdioms;
private List<String> history;
public GameSimulator(SolitaireGraph graph) {
this.graph = graph;
this.usedIdioms = new HashSet<>();
this.history = new ArrayList<>();
}
/**
* 执行一步接龙
*/
public boolean makeMove(String idiomText) {
if (usedIdioms.contains(idiomText)) {
System.out.println("成语已被使用:" + idiomText);
return false;
}
if (!history.isEmpty()) {
String last = history.get(history.size() - 1);
char lastChar = last.charAt(last.length() - 1);
char firstChar = idiomText.charAt(0);
if (lastChar != firstChar) {
System.out.println("接龙不匹配:" + last + " -> " + idiomText);
return false;
}
}
usedIdioms.add(idiomText);
history.add(idiomText);
return true;
}
/**
* AI自动走一步
*/
public boolean aiMove() {
if (history.isEmpty()) {
// AI先手:随机选择一个成语
List<GraphNode> nodes = graph.allNodes;
String choice = nodes.get(new Random().nextInt(nodes.size())).idiom.text;
return makeMove(choice);
}
char lastChar = history.get(history.size() - 1).charAt(
history.get(history.size() - 1).length() - 1);
Idiom choice = graph.chooseNextMove(lastChar, usedIdioms);
if (choice == null) {
System.out.println("AI无法接龙,认输!");
return false;
}
System.out.println("AI选择:" + choice.text);
return makeMove(choice.text);
}
public void printHistory() {
System.out.println("接龙历史:");
for (int i = 0; i < history.size(); i++) {
System.out.print(history.get(i));
if (i < history.size() - 1) System.out.print(" → ");
}
System.out.println("\n总计:" + history.size() + " 条成语");
}
}
/**
* 初始化成语库
*/
public static SolitaireGraph initIdiomDatabase() {
SolitaireGraph graph = new SolitaireGraph();
// 常用成语库(简化版,实际应用中应从数据库加载)
String[][] idiomData = {
{"一马当先", "3"}, {"先声夺人", "3"}, {"人山人海", "2"}, {"海阔天空", "3"},
{"空前绝后", "4"}, {"后继有人", "2"}, {"人云亦云", "3"}, {"云开见日", "3"},
{"日积月累", "2"}, {"累卵之危", "5"}, {"危在旦夕", "3"}, {"夕惕若厉", "5"},
{"厉兵秣马", "4"}, {"马到成功", "2"}, {"功成名就", "3"}, {"。就事论事", "3"},
{"事半功倍", "3"}, {"倍道而行", "4"}, {"行云流水", "3"}, {"水落石出", "2"},
{"出神入化", "3"}, {"化险为夷", "3"}, {"夷为平地", "3"}, {"地平天成", "5"},
{"成竹在胸", "3"}, {"胸有成竹", "2"}, {"竹报平安", "4"}, {"安居乐业", "2"},
{"业精于勤", "3"}, {"勤能补拙", "3"}, {"拙口钝腮", "5"}, {"腮帮鼓鼓", "5"},
{"鼓舞人心", "3"}, {"心旷神怡", "3"}, {"怡然自得", "3"}, {"得心应手", "2"},
{"手不释卷", "3"}, {"卷土重来", "3"}, {"来日方长", "2"}, {"长治久安", "3"},
{"安步当车", "4"}, {"车水马龙", "2"}, {"龙飞凤舞", "3"}, {"舞文弄墨", "4"},
{"墨守成规", "3"}, {"。规行矩步", "4"}, {"步步为营", "3"}, {"营私舞弊", "5"},
{"弊绝风清", "5"}, {"清风明月", "3"}, {"月明星稀", "4"}, {"稀奇古怪", "4"},
{"怪力乱神", "5"}, {"神机妙算", "3"}, {"算无遗策", "5"}, {"策马奔腾", "4"},
{"腾云驾雾", "4"}, {"雾里看花", "3"}, {"花好月圆", "2"}, {"圆满成功", "2"},
{"功德无量", "3"}, {"量体裁衣", "3"}, {"衣锦还乡", "3"}, {"乡音无改", "4"},
{"改天换地", "3"}, {"地大物博", "3"}, {"博学多才", "3"}, {"才高八斗", "3"},
{"斗转星移", "3"}, {"移花接木", "3"}, {"木已成舟", "3"}, {"舟车劳顿", "4"},
{"顿开茅塞", "5"}, {"塞翁失马", "4"}, {"马首是瞻", "4"}, {"瞻前顾后", "3"},
{"后来居上", "3"}, {"上行下效", "3"}, {"效颦学步", "5"}, {"步步高升", "3"},
{"升堂入室", "4"}, {"室如悬磬", "5"}, {"磬竹难书", "5"}, {"。书香门第", "3"},
{"第次鳞比", "5"}, {"比翼双飞", "3"}, {"飞蛾扑火", "3"}, {"火树银花", "4"},
{"花言巧语", "2"}, {"语重心长", "3"}, {"长年累月", "3"}, {"月下老人", "3"},
{"人定胜天", "3"}, {"天经地义", "2"}, {"义不容辞", "3"}, {"辞旧迎新", "3"},
{"新陈代谢", "3"}, {"谢天谢地", "3"}, {"地久天长", "2"}, {"长驱直入", "3"},
{"入木三分", "3"}, {"分秒必争", "3"}, {"争分夺秒", "2"}, {"秒不可言", "5"},
};
for (String[] data : idiomData) {
graph.addIdiom(new Idiom(data[0], Integer.parseInt(data[1])));
}
return graph;
}
public static void main(String[] args) {
System.out.println("=== 成语接龙AI系统 ===\n");
// 初始化成语数据库
SolitaireGraph graph = initIdiomDatabase();
graph.buildGraph();
// 搜索从"一马当先"出发的最长接龙
System.out.println("\n搜索最长接龙路径...");
List<Idiom> longestPath = graph.findLongestPath("一马当先");
System.out.println("从'一马当先'出发的最长接龙(" + longestPath.size() + "条):");
for (int i = 0; i < longestPath.size(); i++) {
System.out.print(longestPath.get(i));
if (i < longestPath.size() - 1) System.out.print(" → ");
}
System.out.println();
// 模拟一局游戏
System.out.println("\n=== 模拟AI对战 ===");
GameSimulator game = new GameSimulator(graph);
// AI先手
game.aiMove();
// AI连续接龙
for (int i = 0; i < 10; i++) {
if (!game.aiMove()) break;
}
game.printHistory();
}
}
四、算法复杂度分析
| 模块 | 时间复杂度 | 空间复杂度 | 说明 |
|---|---|---|---|
| Trie插入 | $O(L)$ | $O(N \cdot L)$ | $N$ 为成语数,$L$ 为平均长度 |
| Trie前缀查询 | $O(L)$ | $O(1)$ | 返回所有匹配结果 |
| 图构建 | $O(N \cdot M)$ | $O(N + E)$ | $M$ 为平均同首字成语数 |
| 记忆化DFS | $O(N \cdot E)$ | $O(N)$ | 最坏情况遍历所有路径 |
| AI决策 | $O(M)$ | $O(1)$ | 选择最优下一步 |
对于实际规模的成语库(数千条成语),系统可在毫秒级完成路径搜索和AI决策。
五、扩展与优化方向
- 同音字支持:使用拼音映射将同音字视为等价,扩大接龙可能性
- 成语难度分级:根据难度等级构建分层图,支持不同水平玩家
- 并行搜索:使用多线程并行搜索多条路径,提高最长路径发现概率
- 对抗策略:引入博弈论思想,选择能最大化对手困难的接龙路径
- 图数据库优化:对于大规模成语库,使用Neo4j等图数据库存储和查询
六、总结
本文从成语接龙这一中国传统文字游戏出发,完整实现了基于Java的智能接龙系统。核心算法涵盖了Trie前缀树高效检索、有向图构建与记忆化DFS最长路径搜索。通过这些算法的组合,系统能够自动发现最长的成语接龙链,并在对战中做出最优决策。希望读者能够理解图论与字符串算法在自然语言处理中的巧妙结合,并将其应用到更多中文信息处理的场景中。