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

相关推荐
weixin_307779133 小时前
软件演示环境动态扩展与成本优化:基于目标跟踪与计划扩展的AWS Auto Scaling策略
算法·云原生·云计算·aws
Carl_奕然3 小时前
【机器视觉】一文掌握常见图像增强算法。
人工智能·opencv·算法·计算机视觉
放羊郎3 小时前
人工智能算法优化YOLO的目标检测能力
人工智能·算法·yolo·视觉slam·建图
无敌最俊朗@3 小时前
友元的作用与边界
算法
Miraitowa_cheems4 小时前
LeetCode算法日记 - Day 104: 通配符匹配
linux·数据结构·算法·leetcode·深度优先·动态规划
程序员东岸4 小时前
从零开始学二叉树(上):树的初识 —— 从文件系统到树的基本概念
数据结构·经验分享·笔记·学习·算法
甄心爱学习5 小时前
数据挖掘11-分类的高级方法
人工智能·算法·分类·数据挖掘
爪哇部落算法小助手5 小时前
每日两题day44
算法
不穿格子的程序员6 小时前
从零开始写算法——二分-搜索二维矩阵
线性代数·算法·leetcode·矩阵·二分查找