【算法】回溯法 01 之单词搜索

129 阅读5分钟

前言

回溯法常用来解决 组合问题搜索问题约束满足问题。 它的核心思想是通过 "试探" 和 "回退" 来探索所有可能的解空间,逐步构建解,发现某条路径不可行时,返回到上一步,尝试其他可能的路径。 它是 **深度优先搜索(DFS)**的策略,结合了递归和条件判断。

深度优先搜索(DFS): 主要思路是从图中一个未访问的顶点 V 开始,沿着一条路一直走到底,然后从这条路尽头的节点回退到上一个节点,再从另一条路开始走到底...,不断递归重复此过程,直到所有的顶点都遍历完成,它的特点是不撞南墙不回头,先走完一条路,再换一条路继续走

前序、中序、后序都是 DFS 的一种

基本思想

1、 逐步尝试:从问题的初始状态开始,按照某种规则逐步尝试每一种可能的选择,构建部分解 2、 约束检查:在每一步尝试中, 检查当前的选择是否满足问题的约束条件。如果不满足就放弃这条路经 3、 回退: 如果当前路径无法继续前行,就撤销上一步的选择,返回之前的决策点,尝试其他选项 4、 终止条件: 当找到一个满足条件的完整解,或者遍历完所有可能性时,停止搜索

执行过程

可以用一个隐式的“解空间树”来理解回溯法的过程:

  • 根节点是问题的初始状态。
  • 每个节点代表一个部分解。
  • 分支表示在当前状态下的一种选择。
  • 叶节点表示一个完整的解(可能是可行解或不可行解)。

回溯法通过递归在树中探索:

  1. 从根节点开始,沿着一条路径向下走(选择一个分支)。
  2. 如果当前路径可行,继续深入;如果不可行,返回上一层。
  3. 重复此过程,直到找到解或遍历完所有路径

经典例子

  1. 八皇后问题

    • 目标:在8×8棋盘上放置8个皇后,使它们互不攻击。
    • 回溯思想:从第一行开始,尝试在每一列放置皇后,检查是否与已放置的皇后冲突。如果冲突,回退到前一行,调整位置。
  2. 迷宫问题

    • 目标:从起点走到终点。

    • 回溯思想:尝试每条可能的路径(上、下、左、右),遇到死路就回退,直到找到出口或确认无解。

回溯法的优点与缺点

  • 优点

    • 系统性:保证不会遗漏任何可能的解。
    • 通用性:适用于许多问题,如排列、组合、图搜索等。
  • 缺点

    • 时间复杂度高:如果不加优化,可能需要指数级时间(O(2^n) 或 O(n!))。
    • 空间复杂度:递归调用栈可能占用较多内存。

优化回溯

为了提高效率,可以引入“剪枝”(Pruning):

  • 可行性剪枝:在扩展路径前,提前判断是否可能成功,避免无意义的尝试。
  • 最优性剪枝:在求最优解时,排除比当前最优解差的路径。

总结

回溯法的思想本质上是“试错与修正”,通过递归探索所有可能性,并在失败时回退到上一步。它是一种优雅而直观的方法,尤其适合需要穷举解空间的问题。实际应用中,结合剪枝和问题特性,可以显著提升效率。

单词搜索

解题思想

  1. 逐步尝试:用网格的每个单元格开始,尝试将其他作为单词的起点,逐步匹配单词的么给个字符
  2. 约束检查:在每一步,检查当前字符是否与单词的对应位置匹配,以及是否越界或重复使用单元格
  3. 回溯: 如果当前路径无法就配单词的下一个字符,就回退到上一步,尝试其他方向(上下左右)
  4. 终止条件:匹配单词成功,或者尝试所有路径都失败

解题思路

  1. 遍历起点: 对网格中的每个单元格,尝试将其作为单词的其实位置
  2. 深度优先搜索:DFS
    • 从当前单元格开始,检查是否匹配每一个单词
    • 如果匹配,标记当前单元格已经使用,然后递归探索四个方向(上下左右)
    • 如果某个方向探索失败,回退并尝试其他方向
  3. 状态恢复: 为了避免重复使用单元格,需要在回退时恢复状态(取消标记)
  4. 剪枝:如果当前字符不匹配,直接返回直白,便面无意义的递归

代码示例

function exist(board: string[][], word: string): boolean {

   let rows = board.length, cols = board[0].length;
   // 分别代表 右、左、上、下,无限制的四个方向遍历
   const directions = [[0, 1], [0, -1], [1, 0], [-1, 0]];

   function dfs(i: number, j: number, k: number): boolean {
       // 如果匹配整个单词
       if (k === word.length) return true;

       // 2、约束条件:检查单词是否越界或者不匹配,3、剪枝就是当前字符不配,直接返回失败
       if (i < 0 || i >= rows || j < 0 || j >= cols || board[i][j] !== word[k]) {
           return false
       }

       const temp: string = board[i][j];
       board[i][j] = "#"; // 使用 “#” 表示已访问

       // 向四个方向遍历
       for (const [di, dj] of directions) {
           if (dfs(i + di, j + dj, k + 1)) {
               return true
           }
       }

       // 恢复状态
       board[i][j] = temp;
       return false

   }

   // 1、从每个单元格开始尝试
   for (let i = 0; i < rows; i++) {
       for (let j = 0; j < cols; j++) {
           if (dfs(i, j, 0)) return true;
       }
   }

   return false

};

复杂度

  • 时间复杂度: O(MN4*L)

    • M 和 N 是网格的行数和列数(遍历起点的数量)
    • L 是单词的长度,每个单元格最多向四个方向递归 L 次
  • 空间复杂度:O(L),递归栈的最大深度等于单词长度