LeetCode 425 - 单词方块


文章目录

摘要

这道题《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。

题解答案

整体解法分成两部分:

  1. 用 Trie 构建一棵前缀树,用于快速查找"某个前缀开头的所有单词"

    因为构造每一行时,我们需要检查前面各行同一列的字符组成的前缀。

  2. 使用回溯(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 题,在实际工程里构建自动补全、智能前缀匹配、搜索推荐系统时也非常常用。

相关推荐
立志成为大牛的小牛10 分钟前
数据结构——五十三、处理冲突的方法——拉链法(王道408)
数据结构·学习·考研·算法
Molesidy16 分钟前
【Embedded Development】嵌入式面试问题汇总(仅供参考)
面试·职场和发展
吃着火锅x唱着歌26 分钟前
LeetCode 3583.统计特殊三元组
算法·leetcode·职场和发展
FPGA_无线通信30 分钟前
OFDM 频偏补偿和相位跟踪(2)
算法·fpga开发
SHOJYS42 分钟前
思维难度较大 贪心优化背包 [USACO22DEC] Bribing Friends G
数据结构·算法·深度优先
啊董dong43 分钟前
课后作业-2025年12月07号作业
数据结构·c++·算法·深度优先·noi
无限进步_1 小时前
C语言宏的魔法:探索offsetof与位交换的奇妙世界
c语言·开发语言·windows·后端·算法·visual studio
Lucky“经营分析”1 小时前
经营分析师-《经营分析能力》
算法
狐571 小时前
2025-12-04-LeetCode刷题笔记-2211-统计道路上的碰撞次数
笔记·算法·leetcode
listhi5201 小时前
激光雷达点云拟合中的ICP(迭代最近点)算法
算法