单词搜索(Word Search)是一款风靡全球的益智文字游戏:玩家在字母组成的二维网格中,找出隐藏在其中的若干单词。规则看似简单——单词可沿上下左右四个方向延伸,且每个格子只能使用一次——但要在海量字母中快速定位所有目标单词,背后需要高效的字符串检索与路径搜索算法协同工作。本文将用Java实现一个带Trie前缀树加速的单词搜索求解器,讲解如何通过前缀匹配大幅剪枝无效搜索分支,并给出完整可运行的代码与复杂度分析。
一、问题建模与算法选型
将单词搜索抽象为图论问题:网格中的每个字母是一个节点,相邻(上下左右)节点之间有边相连。寻找单词的过程等价于在图中寻找一条路径,使得路径上节点的字符依次拼接等于目标单词。
朴素方案是对每个单词分别执行一次DFS,时间复杂度为 O(N * M * 4^L),其中 N*M 为网格大小,L 为单词长度。当单词数量很多时,这种方案会产生大量重复的前缀遍历。
核心优化思路——Trie前缀树:
– 将所有目标单词预先插入Trie树,共享公共前缀节点
– DFS时实时检查当前路径前缀是否存在于Trie中,若不存在则立即剪枝
– 当走到Trie的单词终止节点时,记录找到一个单词,并可继续搜索(允许单词之间共享前缀)
这种”多模式串同时匹配”的策略将时间复杂度从线性增长于单词数量,降低到接近于单次深度优先搜索的开销。
二、Trie前缀树数据结构
Trie树(又称前缀树)是一种有序树状数据结构,用于存储关联数组,其键通常是字符串。每个节点代表一个字符,从根到某节点的路径上的字符序列即为该节点对应的前缀。
import java.util.*;
/**
* Trie前缀树节点
* 每个节点包含:子节点映射、是否为单词结尾标记、到达该节点的完整单词(用于快速收集结果)
*/
class TrieNode {
// 子节点:字符 -> TrieNode,使用HashMap实现O(1)查找
Map<Character, TrieNode> children;
// 标记:从根到该节点是否构成一个完整单词
boolean isEndOfWord;
// 当isEndOfWord为true时,存储完整的单词字符串(避免回溯时重新拼接)
String word;
TrieNode() {
this.children = new HashMap<>();
this.isEndOfWord = false;
this.word = null;
}
}
/**
* Trie前缀树
* 提供插入单词和查询前缀的功能
*/
class Trie {
private final TrieNode root;
Trie() {
this.root = new TrieNode();
}
/**
* 将单词插入Trie树
* 时间复杂度:O(L),L为单词长度
*/
void insert(String word) {
TrieNode node = root;
for (char c : word.toCharArray()) {
// 若当前字符分支不存在,则创建新节点
node = node.children.computeIfAbsent(c, k -> new TrieNode());
}
node.isEndOfWord = true;
node.word = word; // 在终止节点记录完整单词
}
/**
* 获取根节点,用于DFS时从根开始匹配
*/
TrieNode getRoot() {
return root;
}
}
三、回溯搜索与剪枝策略
从网格的每个格子出发,沿着Trie树的边进行深度优先搜索。核心剪枝规则如下:
- 边界剪枝:行或列越界时停止
- 访问剪枝:每个格子只能使用一次,用标记数组记录已访问格子
- 前缀剪枝:当前路径的下一个字符不在Trie当前节点的子节点中时,立即停止该分支
- 单词收集:到达Trie终止节点时,记录单词并从字典中移除(防止重复收集同一单词)
/**
* 单词搜索求解器
* 基于Trie前缀树 + DFS回溯 + 多路剪枝
*/
public class WordSearchSolver {
private final char[][] board;
private final int rows;
private final int cols;
private final Trie trie;
private final List<String> foundWords;
// 四个方向:上、下、左、右
private static final int[][] DIRECTIONS = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
public WordSearchSolver(char[][] board, String[] words) {
this.board = board;
this.rows = board.length;
this.cols = board[0].length;
this.trie = new Trie();
this.foundWords = new ArrayList<>();
// 将所有目标单词插入Trie
for (String word : words) {
trie.insert(word);
}
}
/**
* 主入口:搜索网格中所有存在于字典中的单词
* 返回找到的单词列表
*/
public List<String> findAllWords() {
// 从每个格子出发尝试DFS
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
boolean[][] visited = new boolean[rows][cols];
dfs(i, j, trie.getRoot(), visited);
}
}
return foundWords;
}
/**
* 深度优先搜索
* @param r 当前行
* @param c 当前列
* @param node Trie中当前匹配到的节点
* @param visited 访问标记数组
*/
private void dfs(int r, int c, TrieNode node, boolean[][] visited) {
// 剪枝1:边界检查
if (r < 0 || r >= rows || c < 0 || c >= cols) {
return;
}
// 剪枝2:已访问检查
if (visited[r][c]) {
return;
}
char ch = board[r][c];
// 剪枝3:前缀不存在于Trie中
TrieNode nextNode = node.children.get(ch);
if (nextNode == null) {
return;
}
// 标记当前格子为已访问
visited[r][c] = true;
// 若到达Trie终止节点,找到一个单词
if (nextNode.isEndOfWord) {
foundWords.add(nextNode.word);
// 去重:将终止标记置为false,避免同一单词被多次记录
// 注意:不删除节点,因为该前缀可能还是其他更长单词的前缀
nextNode.isEndOfWord = false;
}
// 向四个方向继续搜索
for (int[] dir : DIRECTIONS) {
int nr = r + dir[0];
int nc = c + dir[1];
dfs(nr, nc, nextNode, visited);
}
// 回溯:恢复访问标记,供其他路径使用
visited[r][c] = false;
}
}
四、进阶优化:Trie剪枝与字典去重
上述实现已能高效工作,但面对极大规模输入时仍有优化空间:
1. 提前移除无分支节点(Trie压缩)
当某个单词被找到后,若其终止节点不再有子节点,则可以沿路径向上删除这些无用节点。这能进一步减少Trie的规模,加速后续搜索的get操作。
/**
* 从Trie中移除已找到的单词,同时清理无用节点
* @param node 当前节点
* @param word 要移除的单词
* @param depth 当前深度
* @return 若当前子树已为空则返回true,否则返回false
*/
private boolean remove(TrieNode node, String word, int depth) {
if (depth == word.length()) {
// 到达终止节点
if (!node.isEndOfWord) {
return false; // 单词不存在
}
node.isEndOfWord = false;
node.word = null;
// 若该节点无子节点,则可以删除
return node.children.isEmpty();
}
char ch = word.charAt(depth);
TrieNode child = node.children.get(ch);
if (child == null) {
return false;
}
boolean shouldDeleteChild = remove(child, word, depth + 1);
if (shouldDeleteChild) {
node.children.remove(ch);
// 若当前节点也不是终止节点且无其他子节点,则也可以删除
return !node.isEndOfWord && node.children.isEmpty();
}
return false;
}
2. 网格字符频率预过滤
在构建Trie前,先统计网格中各字符的出现次数。若某单词包含网格中不存在的字符,或其字符出现次数超过网格中的可用次数,则直接排除,避免插入Trie。
/**
* 预过滤:根据网格字符频率筛除不可能存在的单词
*/
private String[] prefilterWords(String[] words, char[][] board) {
// 统计网格字符频率
Map<Character, Integer> boardFreq = new HashMap<>();
for (char[] row : board) {
for (char c : row) {
boardFreq.merge(c, 1, Integer::sum);
}
}
List<String> filtered = new ArrayList<>();
for (String word : words) {
Map<Character, Integer> wordFreq = new HashMap<>();
for (char c : word.toCharArray()) {
wordFreq.merge(c, 1, Integer::sum);
}
boolean possible = true;
for (Map.Entry<Character, Integer> entry : wordFreq.entrySet()) {
if (boardFreq.getOrDefault(entry.getKey(), 0) < entry.getValue()) {
possible = false;
break;
}
}
if (possible) {
filtered.add(word);
}
}
return filtered.toArray(new String[0]);
}
五、完整运行示例
以下是一个包含主函数的完整可运行示例,演示如何在字母网格中搜索多个单词:
public class Main {
public static void main(String[] args) {
// 定义字母网格
char[][] board = {
{'o', 'a', 'a', 'n'},
{'e', 't', 'a', 'e'},
{'i', 'h', 'k', 'r'},
{'i', 'f', 'l', 'v'}
};
// 目标单词列表
String[] words = {"oath", "pea", "eat", "rain", "oathk", "hkl"};
WordSearchSolver solver = new WordSearchSolver(board, words);
List<String> result = solver.findAllWords();
System.out.println("找到的单词:" + result);
// 预期输出:找到的单词:[oath, eat, hkl, oathk]
// 说明:
// - "oath": (0,0)->(1,1)->(1,2)->(2,2) 即 o->t->a->h
// - "eat": (1,2)->(0,2)->(1,1) 或 (1,2)->(2,2)->(1,1) 即 e->a->t
// - "hkl": (2,1)->(2,2)->(2,3) 即 h->k->l
// - "oathk": (0,0)->(1,1)->(1,2)->(2,2)->(2,1) 即 o->t->a->h->k
}
}
六、复杂度分析
| 维度 | 朴素DFS(逐词搜索) | Trie优化DFS |
|---|---|---|
| 时间复杂度 | O(K × M × N × 4^L) | O(M × N × 4^L) |
| 空间复杂度 | O(L) 递归栈 | O(TotalChars) Trie空间 + O(L) 递归栈 |
| 说明 | K为单词数,需对每个单词单独搜索 | 所有单词共享前缀搜索,L为最长单词长度 |
其中Trie的空间复杂度为所有目标单词的字符总数(共享前缀时会更小)。在实际运行中,前缀剪枝能将搜索空间压缩数个数量级,尤其是当目标单词有大量公共前缀时效果最为显著。
七、总结与延伸
本文通过Java实现了单词搜索游戏的高效求解器,核心要点包括:
- Trie前缀树作为多模式串的共享索引结构,将单词匹配从”逐词线性扫描”升级为”前缀驱动的联合搜索”
- DFS回溯配合访问标记数组,确保每个格子只被使用一次,天然满足游戏规则
- 三级剪枝(边界、访问状态、前缀不存在)层层过滤无效分支,是算法效率的关键
- Trie节点删除与字符频率预过滤作为进阶优化,适用于超大规模输入场景
延伸方向包括:将DFS改为非递归实现以降低栈深度风险、引入双向BFS处理超长单词、使用A*启发式搜索优先探索更有希望的分支,以及将算法扩展到三维字母立方体或允许斜向移动的变体规则中。