单词搜索 II 图解题解
这道题到底在问什么
- 输入
- board = [["o","a","t"],["a","p","e"],["t","e","a"]],words = ["oat","pea","tea"]
- 输出
- ["oat","pea","tea"](三个都能拼出)
最优解:一步一步想明白
- 3记住两条主线:棋盘的 DFS 和字典树的指针「绑在一起同步走」;字典树没分支就剪枝、有结束标记就收词。下面每一帧都在套这套规则。
- 4先把要找的三个单词 oat、pea、tea 压成一棵字典树(共享公共前缀),再让搜索从棋盘每个格子出发。下面演示几个有代表性的起点。
- 5格子的字母 o 正好对上字典树里的分支,落子选中它(橙色是刚走到的格子,浅色是路径上之前选过的)。当前拼出的前缀是 "o",沿着它接着往四周找下一个字母。
- 6格子的字母 a 正好对上字典树里的分支,落子选中它(橙色是刚走到的格子,浅色是路径上之前选过的)。当前拼出的前缀是 "oa",沿着它接着往四周找下一个字母。
- 7格子的字母 t 正好对上字典树里的分支,落子选中它(橙色是刚走到的格子,浅色是路径上之前选过的)。当前拼出的前缀是 "oat",沿着它接着往四周找下一个字母。
- 8走到这里,字典树的这个节点挂着结束标记,意味着路径上的字母刚好拼成了目标单词 "oat"!把它收进答案。为了不重复收录,把这个单词从字典树上摘掉。
- 9从这里往这个方向探一步,字母是 e。字典树里当前节点没有 e 这条分支,说明 "oate" 不可能拼成任何目标单词,直接掐断这条路,不再往下走。
- 10从这里往这个方向探一步,字母是 p。字典树里当前节点没有 p 这条分支,说明 "oap" 不可能拼成任何目标单词,直接掐断这条路,不再往下走。
- 11格子的字母 a 正好对上字典树里的分支,落子选中它(橙色是刚走到的格子,浅色是路径上之前选过的)。当前拼出的前缀是 "oa",沿着它接着往四周找下一个字母。
- 12从这里往这个方向探一步,字母是 p。字典树里当前节点没有 p 这条分支,说明 "oap" 不可能拼成任何目标单词,直接掐断这条路,不再往下走。
- 13格子的字母 t 正好对上字典树里的分支,落子选中它(橙色是刚走到的格子,浅色是路径上之前选过的)。当前拼出的前缀是 "oat",沿着它接着往四周找下一个字母。
- 14从这里往这个方向探一步,字母是 e。字典树里当前节点没有 e 这条分支,说明 "oate" 不可能拼成任何目标单词,直接掐断这条路,不再往下走。
- 15格子的字母 p 正好对上字典树里的分支,落子选中它(橙色是刚走到的格子,浅色是路径上之前选过的)。当前拼出的前缀是 "p",沿着它接着往四周找下一个字母。
- 16格子的字母 e 正好对上字典树里的分支,落子选中它(橙色是刚走到的格子,浅色是路径上之前选过的)。当前拼出的前缀是 "pe",沿着它接着往四周找下一个字母。
- 17格子的字母 a 正好对上字典树里的分支,落子选中它(橙色是刚走到的格子,浅色是路径上之前选过的)。当前拼出的前缀是 "pea",沿着它接着往四周找下一个字母。
- 18走到这里,字典树的这个节点挂着结束标记,意味着路径上的字母刚好拼成了目标单词 "pea"!把它收进答案。为了不重复收录,把这个单词从字典树上摘掉。
- 19从这里往这个方向探一步,字母是 e。字典树里当前节点没有 e 这条分支,说明 "peae" 不可能拼成任何目标单词,直接掐断这条路,不再往下走。
- 20从这里往这个方向探一步,字母是 t。字典树里当前节点没有 t 这条分支,说明 "pet" 不可能拼成任何目标单词,直接掐断这条路,不再往下走。
- 21从这里往这个方向探一步,字母是 a。字典树里当前节点没有 a 这条分支,说明 "pa" 不可能拼成任何目标单词,直接掐断这条路,不再往下走。
- 22格子的字母 e 正好对上字典树里的分支,落子选中它(橙色是刚走到的格子,浅色是路径上之前选过的)。当前拼出的前缀是 "pe",沿着它接着往四周找下一个字母。
- 23格子的字母 a 正好对上字典树里的分支,落子选中它(橙色是刚走到的格子,浅色是路径上之前选过的)。当前拼出的前缀是 "pea",沿着它接着往四周找下一个字母。
- 24从这里往这个方向探一步,字母是 e。字典树里当前节点没有 e 这条分支,说明 "peae" 不可能拼成任何目标单词,直接掐断这条路,不再往下走。
- 25从这里往这个方向探一步,字母是 t。字典树里当前节点没有 t 这条分支,说明 "pet" 不可能拼成任何目标单词,直接掐断这条路,不再往下走。
- 26从这里往这个方向探一步,字母是 a。字典树里当前节点没有 a 这条分支,说明 "pa" 不可能拼成任何目标单词,直接掐断这条路,不再往下走。
- 27格子的字母 t 正好对上字典树里的分支,落子选中它(橙色是刚走到的格子,浅色是路径上之前选过的)。当前拼出的前缀是 "t",沿着它接着往四周找下一个字母。
- 28从这里往这个方向探一步,字母是 a。字典树里当前节点没有 a 这条分支,说明 "ta" 不可能拼成任何目标单词,直接掐断这条路,不再往下走。
- 29格子的字母 e 正好对上字典树里的分支,落子选中它(橙色是刚走到的格子,浅色是路径上之前选过的)。当前拼出的前缀是 "te",沿着它接着往四周找下一个字母。
- 30从这里往这个方向探一步,字母是 p。字典树里当前节点没有 p 这条分支,说明 "tep" 不可能拼成任何目标单词,直接掐断这条路,不再往下走。
- 31格子的字母 a 正好对上字典树里的分支,落子选中它(橙色是刚走到的格子,浅色是路径上之前选过的)。当前拼出的前缀是 "tea",沿着它接着往四周找下一个字母。
- 32走到这里,字典树的这个节点挂着结束标记,意味着路径上的字母刚好拼成了目标单词 "tea"!把它收进答案。为了不重复收录,把这个单词从字典树上摘掉。
- 33从这里往这个方向探一步,字母是 e。字典树里当前节点没有 e 这条分支,说明 "teae" 不可能拼成任何目标单词,直接掐断这条路,不再往下走。
- 34所有起点搜完,棋盘上能拼出的目标单词是 oat、pea、tea,一共 3 个,这就是最终答案。
⚠️ 容易写错的地方
✗ 错:对每个单词单独在棋盘里搜一遍
✓ 对:先建字典树,一次 DFS 顺前缀同时找所有词
单独搜会把 oat、oak 这种共享前缀的路重复走多次,建树把公共前缀合并只走一次
✗ 错:忘记回溯时还原格子('#' 没改回)
✓ 对:进格子标 '#',四个方向递归完再改回原字母
不还原会让别的起点路径误以为这格已被占,漏掉本该能拼出的单词
✗ 错:找到单词后不去重,重复收录
✓ 对:收词时把结束标记摘掉(pop '$' / word 置空)
同一个单词可能从不同路径再次走到结束节点,不去重会在答案里出现重复
完整代码(Python / C++ / Java)
Python
def findWords(board, words):
trie = {} # 用嵌套字典当字典树
for w in words:
node = trie
for ch in w: node = node.setdefault(ch, {})
node['$'] = w # 结束标记存整词
R, C, res = len(board), len(board[0]), []
def dfs(r, c, node):
ch = board[r][c]
nxt = node.get(ch)
if nxt is None: return # 无分支 → 剪枝
if '$' in nxt:
res.append(nxt.pop('$')) # 收词并去重
board[r][c] = '#' # 标记已用
for dr, dc in ((1,0),(-1,0),(0,1),(0,-1)):
nr, nc = r+dr, c+dc
if 0<=nr<R and 0<=nc<C and board[nr][nc] != '#':
dfs(nr, nc, nxt)
board[r][c] = ch # 回溯还原
for r in range(R):
for c in range(C): dfs(r, c, trie)
return resC++
struct Node { Node* ch[26]={}; string word; };
vector<string> findWords(vector<vector<char>>& b, vector<string>& ws){
Node* root = new Node();
for (auto& w : ws){ Node* n=root;
for (char c: w){ int k=c-'a'; if(!n->ch[k]) n->ch[k]=new Node(); n=n->ch[k]; }
n->word = w; }
int R=b.size(), C=b[0].size(); vector<string> res;
function<void(int,int,Node*)> dfs=[&](int r,int c,Node* n){
char ch=b[r][c]; if(ch=='#') return;
Node* nx=n->ch[ch-'a']; if(!nx) return; // 剪枝
if(!nx->word.empty()){ res.push_back(nx->word); nx->word.clear(); }
b[r][c]='#';
int dr[]={1,-1,0,0}, dc[]={0,0,1,-1};
for(int k=0;k<4;k++){ int nr=r+dr[k],nc=c+dc[k];
if(nr>=0&&nr<R&&nc>=0&&nc<C) dfs(nr,nc,nx); }
b[r][c]=ch; // 回溯
};
for(int r=0;r<R;r++) for(int c=0;c<C;c++) dfs(r,c,root);
return res;
}Java
class Node { Node[] ch = new Node[26]; String word; }
public List<String> findWords(char[][] b, String[] ws){
Node root = new Node();
for (String w : ws){ Node n=root;
for (char c: w.toCharArray()){ int k=c-'a';
if(n.ch[k]==null) n.ch[k]=new Node(); n=n.ch[k]; }
n.word = w; }
List<String> res = new ArrayList<>();
int R=b.length, C=b[0].length;
int[][] DIR={{1,0},{-1,0},{0,1},{0,-1}};
java.util.function.BiConsumer<int[],Node> dummy=null;
// 用递归方法 dfs(r,c,node):取 nx=node.ch[b[r][c]-'a']
// nx==null 剪枝;nx.word!=null 收词并置空;标 '#' 后回溯还原
for(int r=0;r<R;r++) for(int c=0;c<C;c++) dfs(b,r,c,root,res,R,C,DIR);
return res;复杂度
时间
O(M·4·3^(L-1))
M 个起点格子,每条路径最多 4 个方向、深度为最长单词长 L;字典树把不可能的前缀提前剪掉,实际远小于此上界
空间
O(N)
N 是所有单词的总字母数,字典树最多存这么多节点;递归栈深度为最长单词长度 L
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 单词搜索 II 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
DFS 时棋盘指针和字典树指针是什么关系?+
它们绑在一起同步移动:棋盘往某个相邻格子走,字典树就走到该格字母对应的子节点。两边字母必须一致,否则该方向无效(字典树没这个分支就剪枝)。
怎么避免同一个格子在一个单词里被重复使用?+
进入格子时把它临时改成特殊标记(如 '#'),递归探完四个方向后再改回原字母。这样递归过程中这格被视为已占用,回溯后又恢复供其他路径使用。
找到一个单词后为什么要把它从字典树上摘掉?+
去重。同一个单词可能从不同路径再次到达它的结束节点,摘掉结束标记后第二次到达就不会重复收录,也顺带减少后续无谓的命中判断。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 单词搜索 II 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
把这道题真正拿下
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。