

文章目录
摘要
这道题《LeetCode 425. 单词方块》比一般的字符串题更"烧脑"一些,因为它不仅要求词语之间互相匹配,还要求每一行和每一列都构成有效的单词。
如果说 Trie(前缀树)在 LeetCode 里是"偶尔能用",那么这题可以说是让 Trie 发挥最大价值的场景之一:我们需要频繁查询"某个前缀开头的所有单词"。
整个题的核心其实就一句话:用前缀树加回溯不断构造方块。

描述
题目给定一组长度相同的单词,例如:
txt
words = ["area","lead","wall","lady","ball"]
我们需要找出所有符合下面规则的"单词方块":
- 单词方块指的是:第 i 行的第 j 个字符必须等于第 j 行的第 i 个字符。
举个例子,一个 4x4 的单词方块长这样:
txt
wall
area
lead
lady
你会发现:
- 第一行第四个字母
l等于第四行第一个字母l - 第二行第三个字母
e等于第三行第二个字母e - ... 所有对应位置都互相匹配
也就是说,它在横向和纵向读出来都一样。
输入的单词长度为 k,那么最终构造出的方块也是 k x k。

题解答案
整体解法分成两部分:
-
用 Trie 构建一棵前缀树,用于快速查找"某个前缀开头的所有单词"
因为构造每一行时,我们需要检查前面各行同一列的字符组成的前缀。
-
使用回溯(Depth-First Search)逐步构建方块
每添加一行,都根据当前构建出来的前缀去 Trie 查所有可能的候选词,继续递归。
只要某个方向不满足条件,就立即剪枝,这样能省掉非常多不必要的尝试。
整个算法看起来像在做"文字数独",但 Trie 的加入让效率非常高。
题解代码分析
下面是完整可运行的 Swift 版本(包含 Trie 实现 + 回溯过程):
swift
import Foundation
// MARK: - Trie 节点结构
class TrieNode {
var children: [Character: TrieNode] = [:]
var words: [String] = [] // 所有以当前前缀开头的单词
}
// MARK: - Trie 前缀树
class Trie {
let root = TrieNode()
func insert(_ word: String) {
var node = root
for ch in word {
if node.children[ch] == nil {
node.children[ch] = TrieNode()
}
node = node.children[ch]!
node.words.append(word)
}
}
func findWords(byPrefix prefix: String) -> [String] {
var node = root
for ch in prefix {
guard let next = node.children[ch] else {
return []
}
node = next
}
return node.words
}
}
// MARK: - 主解法
class Solution {
func wordSquares(_ words: [String]) -> [[String]] {
guard !words.isEmpty else { return [] }
let trie = Trie()
// 所有单词都用来构建 Trie
for word in words {
trie.insert(word)
}
var results: [[String]] = []
var square: [String] = []
let wordLength = words[0].count
// 从每个单词开始构建
for word in words {
square = [word]
backtrack(&results, &square, trie, wordLength)
}
return results
}
private func backtrack(_ results: inout [[String]],
_ square: inout [String],
_ trie: Trie,
_ length: Int) {
// 如果填满一个完整方块就记录结果
if square.count == length {
results.append(square)
return
}
// 根据列构造前缀
var prefix = ""
let idx = square.count
for word in square {
let chars = Array(word)
prefix.append(chars[idx])
}
// 查询所有匹配前缀的候选单词
let candidates = trie.findWords(byPrefix: prefix)
// 尝试每个候选
for candidate in candidates {
square.append(candidate)
backtrack(&results, &square, trie, length)
square.removeLast()
}
}
}
// MARK: - Demo 测试
let words = ["area", "lead", "wall", "lady", "ball"]
let s = Solution()
let result = s.wordSquares(words)
print("所有有效单词方块:")
for square in result {
for row in square {
print(row)
}
print("------")
}
代码模块分析
这里我们分几个重点部分来讲。
1. Trie 存储所有前缀匹配的单词
TrieNode 中有个 words 数组,用来存储"所有以该前缀开头的单词"。
这样查找前缀变得非常快,只需要沿着字符往下走就可以获得完整候选列表,而不是在整个词库中筛选。
这在构建方块时非常关键,因为每一行都需要根据前几行的列前缀进行查找。
2. 回溯构建单词方块
每一个位置都必须满足:
txt
square[rowIndex][colIndex] == square[colIndex][rowIndex]
但我们不会一个字符一个字符试,而是整行整行地试。
假设目前 square 中已有两行:
txt
wall
area
我们要添加第三行时,需要根据纵向得到位置 (2,0)(2,1)(2,2) 的前缀:
txt
l e a
然后去 Trie 查所有以 "lea" 开头的单词。
这就是前缀树让我们能高效查找下一个"合法候选"的原因。
3. 剪枝
如果某个前缀在 Trie 中找不到任何单词,就直接终止当前分支。
大大节省计算量。
示例测试及结果
假设输入:
txt
["area", "lead", "wall", "lady", "ball"]
运行结果:
txt
所有有效单词方块:
wall
area
lead
lady
------
ball
area
lead
lady
------
说明程序运行正常,也找到了题目示例中的两个有效方块。
你可以随便再加一些字符串测试,比如只给同一个长度的前缀词表,它也会正常跑。
时间复杂度
时间复杂度比较难算,因为回溯的分支数量依赖于前缀相匹配的单词数量。大概可以拆解如下:
- 构建 Trie:O(n * k),n 是单词数量,k 是单词长度
- 回溯:最坏情况可能接近 O(n^k),但通过前缀剪枝,通常远低于暴力
在实际测试数据中,由于前缀树剪枝很有效,整体性能是能通过 LeetCode 的。
空间复杂度
- Trie 占用:O(n * k)
- 回溯栈深度最多为 k
- 字符前缀搜索缓存为常数级
整体空间复杂度是 O(n * k)。
总结
这道题本质是前缀匹配问题的拓展版,而前缀树正好是最契合的解决方案。
关键点在于:
- 构建 Trie,使得"根据前缀找所有单词"的操作变得高效
- 使用回溯逐步构造方块
- 通过前缀剪枝避免无效搜索
这种算法不仅能解决 LeetCode 题,在实际工程里构建自动补全、智能前缀匹配、搜索推荐系统时也非常常用。