云计算百科
云计算领域专业知识百科平台

一天一道力扣Hot100(38):深度优先算法---单词搜索

LeetCode 79. 单词搜索

个人主页:
> 我不会起名字322 < (欢迎各位大佬莅临😊)

其他栏目:
> 技术栈学习笔记 <

其他栏目:
> 力扣Hot100题目解析 <

其他栏目:
> Go项目学习笔记 <




前言:

上一篇括号生成里,我们再次用回溯的"三要素"——路径、选择列表、结束条件——把一道看起来和组合总和完全不同的题给套了进去。当时我说:题目变了,形式变了,但三要素的骨架没变。

今天这道单词搜索,是对这句话的又一次检验,而且是更狠的一次——它连"一维数组"都不是了,直接把战场搬到了二维网格上。选择列表不再是一个数组、也不是两个计数器,而是当前位置的上下左右四个邻居。但你别慌,我们一起来看看,三要素在这道题里分别变成了什么。

  • 首先我们说DFS到底是干了什么事:DFS 是一种遍历策略;当它不设终点、走遍决策树的所有分支时,就是在穷举。回溯题用 DFS 穷举所有决策路径,并在满足条件处收集答案。这道题稍微特殊一点——我们不是"收集所有答案",而是"只要找到一条就立刻收手",所以更适合用一个 found 标志位来提前终止。

  • 路径(已做的选择),这是回溯题要看的第一个数据。这道题的路径就是当前已经匹配到 word 的第几个字符,用一个下标 index 就能表示。注意,我们不需要真的去拼一个字符串出来——因为 word 是给定的,只要 index 走到 len(word),就说明整条路径和 word 完全对上了。

  • 选择列表(当前可做的选择),这是回溯要看的第二个数据。这道题的选择列表是由当前位置 (i, j) 动态决定的:上、下、左、右四个邻居里,哪些在网格内、哪些字符等于 word[index]、哪些还没被用过?三者都满足的,才是当前可做的选择。

  • 结束条件(到达决策树的底层),这是回溯要看的第三个数据。这里就是**index == len(word)**,也就是整个单词都匹配完了,此时把 found 置为 true。

  • 整体的一个模板还是这样的:

    func backtrack(路径, 选择列表) {
    if 满足结束条件 {
    结果集 = append(结果集, 路径的拷贝)
    return
    }
    for 选择 := 选择列表 {
    做选择 // 路径加入该选择
    backtrack(路径, 新的选择列表)
    撤销选择 // 路径移除该选择
    }
    }

    下面我们来看一道题目深入理解一下

    给定一个 m x n 二维字符网格 board 和一个字符串单词 word 。如果 word 存在于网格中,返回 true ;否则,返回 false 。

    单词必须按照字母顺序,通过相邻的单元格内的字母构成,其中"相邻"单元格是那些水平相邻或垂直相邻的单元格。同一个单元格内的字母不允许被重复使用。

    示例 1:

    输入:board = [['A','B','C','E'],['S','F','C','S'],['A','D','E','E']], word = "ABCCED"
    输出:true

    示例 2:

    输入:board = [['A','B','C','E'],['S','F','C','S'],['A','D','E','E']], word = "SEE"
    输出:true

    示例 3:

    输入:board = [['A','B','C','E'],['S','F','C','S'],['A','D','E','E']], word = "ABCB"
    输出:false

    提示:

    • m == board.length
    • n = board[i].length
    • 1 <= m, n <= 6
    • 1 <= word.length <= 15
    • board 和 word 仅由大小写英文字母组成

    搜索的规则

    在动手写代码之前,先想清楚"一次合法的搜索"到底要满足什么。有三个约束缺一不可:

  • 范围约束:新位置 (ni, nj) 必须在网格内,即 0 <= ni < m 且 0 <= nj < n
  • 字符约束:board[ni][nj] 必须等于 word[index],否则这条路走不通
  • 不重复约束:(ni, nj) 这个格子在这一轮搜索里还没被用过
  • 第 3 条尤其关键。比如示例 3 的 "ABCB",走完 A→B→C 之后想再走回 B,但那个 B 已经被用过了,所以不合法。这个约束直接决定了我们要引入一个 used 数组——或者,更巧妙地,直接在 board 上原地修改来标记(这是后话)。

    • 首先,我们不需要排序,也不需要一维数组。这道题的输入是一个二维字符网格 board 和一个字符串 word,没有候选数组,所以组合总和里的 sort.Ints 和 startIndex 在这里都用不上。这正说明:三要素是本质,具体形式随题目而变。

    • 其次,我们要定义几个数据

    • 一个是当前匹配到 word 的第几个字符(路径)

      index int // 表示 path 已经匹配到 word[index]

      注意:这里不像括号生成那样传一个 path []byte,因为 word 本身就是要匹配的"目标路径",我们只需要一个下标来记录进度即可。

    • 一个是结果标志位,用来记录是否已经找到

      found := false

      因为这道题只要求返回 true / false,不需要收集所有解,所以用一个布尔值就够了。

    • 上面两个是确定的,最后一个看题目不同来自己确定。这道题我们需要记录哪些格子已经被用过,用来避免重复访问

      used := make([][]bool, m)
      for i := range used {
      used[i] = make([]bool, n)
      }

      注意:这里不像括号生成那样传 open, close 两个计数器,因为括号题统计的是"数量",而单词搜索要防的是"同一个格子被重复踩"。

    • 还有一个方向数组,这是网格类题目的固定套路,把上下左右四个方向写死:

      dirs := [4][2]int{{1, 0}, {1, 0}, {0, 1}, {0, 1}}

    • 有了上面的数据,我们现在来套用模板来写这道题目

      func backtrack(路径, 选择列表) {
      首先是这个大框架,路径肯定要传 index,选择列表呢?
      这道题的选择列表不是数组,而是由 (i, j) 和 used 动态决定的:
      四个邻居中,在网格内 + 字符等于 word[index] + 未被 used 的,才是可选项
      }

      if 满足结束条件 {
      这里的满足条件就是 index == len(word)

      这里的"路径拷贝"其实就是把 found 置为 true —— 因为我们不需要真的把路径写出来,
      只要走到了底,就说明整条路径和 word 完全对上了
      found = true
      return
      }
      //这里我们要想,如果还能往下走怎么办呢?显然,继续下面的递归即可
      //那如果四个方向都走不通呢?那就自然返回,让上一层去尝试别的方向

      // 遍历四个方向(选择列表)
      for _, d := range dirs {
      ni, nj := i+d[0], j+d[1]
      // 越界 或者 字符不对 或者 已经用过,就跳过(这就是剪枝)
      if ni < 0 || ni >= m || nj < 0 || nj >= n ||
      board[ni][nj] != word[index] || used[ni][nj] {
      continue
      }

      used[ni][nj] = true // 做选择
      backtrack(ni, nj, index+1) // 递归
      used[ni][nj] = false // 撤销选择
      }

      注意一个细节:这道题和前面几道不一样——递归的入口不是"某个固定起点",而是"任意一个等于 word[0] 的格子"。所以最外层要套一个双重循环:

      for i := 0; i < m; i++ {
      for j := 0; j < n; j++ {
      if board[i][j] == word[0] {
      // 从 (i, j) 出发搜索
      used[i][j] = true
      backtrack(i, j, 1)
      used[i][j] = false
      }
      }
      }

      只要中途 found 变成了 true,就可以直接返回 true,不用继续搜了——这就是"只找一条解"和"收集所有解"的区别。

    • 因此,我们最后改造的函数就是

      func exist(board [][]byte, word string) bool {
      m, n := len(board), len(board[0])
      used := make([][]bool, m)
      for i := range used {
      used[i] = make([]bool, n)
      }
      found := false
      dirs := [4][2]int{{1, 0}, {1, 0}, {0, 1}, {0, 1}}

      var backtrack func(i, j, index int)
      backtrack = func(i, j, index int) {
      // 结束条件:整个单词都匹配完了
      if index == len(word) {
      found = true
      return
      }

      // 遍历四个方向(选择列表)
      for _, d := range dirs {
      ni, nj := i+d[0], j+d[1]
      // 越界 或者 字符不对 或者 已经用过,就跳过(这就是剪枝)
      if ni < 0 || ni >= m || nj < 0 || nj >= n ||
      board[ni][nj] != word[index] || used[ni][nj] {
      continue
      }

      used[ni][nj] = true // 做选择
      backtrack(ni, nj, index+1) // 递归
      used[ni][nj] = false // 撤销选择

      // 已经找到就直接退出,不用继续搜了
      if found {
      return
      }
      }
      }

      // 枚举所有可能的起点
      for i := 0; i < m; i++ {
      for j := 0; j < n; j++ {
      if board[i][j] == word[0] {
      used[i][j] = true
      backtrack(i, j, 1)
      used[i][j] = false
      if found {
      return true
      }
      }
      }
      }
      return false
      }

    • 进阶:能不能省掉 used 数组?

      可以。仔细观察会发现,used 数组的唯一作用就是"防止走回头路"。但我们完全可以在原地修改 board:进去的时候把 board[i][j] 改成一个不可能出现在 word 里的字符(比如 '#'),出来的时候再改回去。

      func exist(board [][]byte, word string) bool {
      m, n := len(board), len(board[0])
      dirs := [4][2]int{{1, 0}, {1, 0}, {0, 1}, {0, 1}}

      var dfs func(i, j, index int) bool
      dfs = func(i, j, index int) bool {
      // 结束条件:整个单词都匹配完了
      if index == len(word) {
      return true
      }
      // 越界 或者 字符不对,就跳过
      if i < 0 || i >= m || j < 0 || j >= n || board[i][j] != word[index] {
      return false
      }

      tmp := board[i][j]
      board[i][j] = '#' // 做选择:标记已访问

      for _, d := range dirs {
      if dfs(i+d[0], j+d[1], index+1) {
      return true
      }
      }

      board[i][j] = tmp // 撤销选择:恢复现场
      return false
      }

      for i := 0; i < m; i++ {
      for j := 0; j < n; j++ {
      if dfs(i, j, 0) {
      return true
      }
      }
      }
      return false
      }

      这份代码比上一份更短、更省空间,而且把"做选择 / 撤销选择"直接体现在 board 的修改和恢复上——这就是回溯三要素最纯粹的样子。尤其注意 board[i][j] = '#' 和 board[i][j] = tmp 这一对操作,它们就是标准的"做选择 / 撤销选择"。

    复杂度分析

    • 时间复杂度:O(m × n × 3^L),其中 L = len(word)。最坏情况下每个起点都要搜一遍,每次从 4 个方向里选一个方向继续(因为不能走回头路,所以实际分支约 3 个),深度为 L。
    • 空间复杂度:O(L),递归栈深度(不算输入输出)。原地修改版本不需要额外的 used 数组。

    总结

    回过头看,这道题再次印证了"回溯三要素":

    三要素在单词搜索里的体现
    路径 index,记录当前匹配到 word 的第几个字符
    选择列表 由 (i, j) 动态决定:上下左右四个邻居里,合法的那些
    结束条件 index == len(word) 时把 found 置为 true

    和前面几道题对比一下,区别一目了然:

    组合总和括号生成单词搜索
    路径 path []int path []byte index int
    选择列表 startIndex 之后的数组元素 open/close 动态判断 (i,j) 的上下左右邻居
    结束条件 curSum == target len(path) == 2*n index == len(word)
    剪枝 curSum + candidates[i] > target close < open 越界 / 字符不对 / 已访问
    是否收集所有解 否(找到一条就收手)

    题目变了,形式变了,但三要素的骨架没变。这就是为什么我说"想清楚这三样,剩下的就是套模板"。

    本文是 《算法题目解析系列》 的第 [38] 篇,本系列将持续更新,每篇都提供清晰的思路与编程语言实现。欢迎关注,第一时间获取更新。如果你有想看的题目,也可以在评论区留言告诉我。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 一天一道力扣Hot100(38):深度优先算法---单词搜索
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!