LC 79 单词搜索:都说这是回溯入门题,我却连错三回

刷回溯算法的路上,这道单词搜索绝对是绕不开的入门经典。 我第一次碰它的时候,心里还嘀咕:不就是个二维网格深搜吗?能有多难? 结果啪啪打脸,连提交三次都没过,最后排查半天 ------ 我居然把 "回溯" 这俩字忘干净了。

题目说啥的

先快速说下题意,很简单。 给你一个装满字母的二维网格,再给一个目标单词。 你可以从网格里任意一个格子出发,沿着上下左右四个方向走相邻的格子,每个格子只能用一次,问能不能按顺序拼出这个单词。

我是怎么想到解法的

这种 "找路径、不能重复、走不通就退回来" 的题,其实天生就该用 DFS + 回溯来做。 我一开始大方向是对的,知道要深度优先搜四个方向。 但写的时候光顾着往前冲了,完全没考虑 "这条路走不通,退回去的时候得把访问标记擦掉"。 结果遇到相邻两个相同字母的时候,程序来回走,直接死循环栈溢出了。

其实整个流程特别朴素: 我们遍历网格里的每一个格子,都当作单词的起始点。 只要当前格子的字母,和单词当前位置的字母对得上,就接着往四个方向搜下一个字母。 搜之前,得把当前格子在 used 数组里标记成已访问,防止回头重复走。 如果四个方向全走不通,那就把标记改回未访问,也就是回溯,回到上一步换别的方向试。 什么时候算赢?当我们顺利匹配到单词的最后一个字符,那就直接返回 true。

就拿示例里的 ABCCED 来说,从左上角 A 出发,往右走到 B,再往右到 C,往下走到另一个 C,再往下到 E,往左走到 D------ 刚好六个字母全对上,就返回 true。

我最开始写的就是这种额外开 used 数组的写法,好处是完全不会修改原始的 board 数组。 面试的时候如果面试官要求不能改动输入,这种写法更稳妥,逻辑也更直观,不容易写乱。

拿示例 1 走一遍完整 DFS 流程

光说有点抽象,咱们拿示例 1 的 ABCCED 实打实走一遍,你一下就懂了。

示例的网格长这样: 行 0:A B C E 行 1:S F C S 行 2:A D E E

我们从左上角 (0,0) 这个 A 出发,此时 idx = 0,对应单词第一个字母 A。 字符对上了,而且还没到最后一个字母,于是把 used[0][0] 标记成 true。

接下来往四个方向找下一个字母 B(idx=1)。 四个方向里,只有右边 (0,1) 是 B,其他方向要么越界要么不对。 走到 (0,1),字符匹配,标记 used[0][1] = true

继续找下一个字母 C(idx=2)。 (0,1) 的四个方向里,只有右边 (0,2) 是 C,其他都不对。 走到 (0,2),字符匹配,标记 used[0][2] = true

接着找下一个字母 C(idx=3)。 (0,2) 的四个方向:上边越界,左边的 B 已经用过了,右边是 E 不对,只有下边 (1,2) 是 C。 走到 (1,2),字符匹配,标记 used[1][2] = true

再找下一个字母 E(idx=4)。 (1,2) 的四个方向:上边的 C 用过了,左边是 F 不对,右边是 S 不对,只有下边 (2,2) 是 E。 走到 (2,2),字符匹配,标记 used[2][2] = true

最后找字母 D(idx=5),这已经是单词的最后一个字符了。 (2,2) 的四个方向:上边的 C 用过了,下边越界,右边是 E 不对,只有左边 (2,1) 是 D。 走到 (2,1),字符 D 和 word[5] 对上了,而且 idx 刚好等于单词长度减一,直接返回 true。

整条路径就这么通了,成功的情况下不用再往回走撤销标记,直接一路返回 true 到最外层。 如果这条路走不通,才会一层层把 used 标记撤销,回去试别的方向。

代码实现(JavaScript)

JavaScript 复制代码
var exist = function(board, word) {
    const m = board.length, n = board[0].length;
    // 四个方向:上下左右
    const dirs = [[-1,0],[1,0],[0,-1],[0,1]];
    // 记录每个格子是否被用过,防止重复走同一路径
    const used = Array.from({ length: m }, () => Array(n).fill(false));

    const dfs = (i, j, idx) => {
        // 当前格子字符对不上,这条路直接废了
        if (board[i][j] !== word[idx]) return false;
        // 已经匹配到单词最后一个字符,匹配成功
        // 别把这个判断写在最前面,我一开始写反了,边界情况疯狂报错
        if (idx === word.length - 1) return true; 

        // 标记当前格子已访问,后面不能再走回来
        used[i][j] = true;
        // 四个方向挨个尝试
        for (const [dx, dy] of dirs) {
            const ni = i + dx, nj = j + dy;
            // 先卡边界 + 没被访问过,再往下递归
            if (ni >= 0 && ni < m && nj >= 0 && nj < n && !used[ni][nj]) {
                if (dfs(ni, nj, idx + 1)) return true;
            }
        }
        // 四个方向都走不通,回溯:撤销访问标记
        // 这行千万别漏!我第一次写忘加了,搜过一次的格子就再也用不了了
        used[i][j] = false;
        return false;
    };

    // 每个格子都能当起点,挨个尝试
    for (let i = 0; i < m; i++) {
        for (let j = 0; j < n; j++) {
            if (dfs(i, j, 0)) return true;
        }
    }
    return false;
};

我踩过的三个坑

说几个我实打实踩过的坑,你们避避雷。 第一个,终点判断的顺序。这里我一开始把 idx === word.length - 1 写在了字符判断前面,结果边界情况直接出问题。而且要注意,这里是判断等于 length-1,和那种 "先递归再判终点" 的写法不一样,别搞混了。 第二个,忘记回溯撤销 used 标记。这应该是很多人第一次写都会犯的错,只知道打标记,不知道撤掉。后果就是前面的路径把格子占了,后面的路径根本没法走,怎么测都不对。 第三个,重复写越界判断。这个写法里,递归调用前已经做了边界和访问状态的校验,进入 dfs 的坐标一定是合法的,不用在函数开头再判断一遍。我一开始两头都写,不仅冗余还容易写出逻辑 bug。

复杂度分析

时间上最坏情况,每个格子都当起点,每一步除了来的方向还有 3 个选择,走 L 步(L 是单词长度),大概是 O (mn 3^L)。 空间上除了递归栈的深度 L,还有一个 mn 的 used 访问数组,整体是 O (mn + L)。 如果想省空间,也可以改成直接修改原网格标记访问,不用额外数组,空间就能降到 O (L)。

关于进阶优化

题目最后问大数据量怎么优化,其实可以加些剪枝。 比如先统计网格里每个字母出现的次数,如果单词里有字母网格里根本没有,直接返回 false,连搜都不用搜。 还可以看单词首尾哪个字母在网格里出现得少,就从哪头开始搜,能少遍历很多起点。

最后唠两句

说到底这道题就是回溯的经典模板题 ------ 选择、递归、撤销选择。 道理听着简单,真上手写很容易在细节上翻车,反正我是在回溯这步栽过跟头,印象特别深。

你刷这道题的时候有没有踩过什么有意思的坑?或者有更妙的剪枝技巧?评论区聊聊,我每条都会看~ 觉得有帮助的话点个赞就更好啦。

相关推荐
胡萝卜术5 小时前
从暴力到滑动窗口的终极形态:力扣3「无重复字符的最长子串」的优化进化之路
前端·javascript·面试
问商十三载6 小时前
2026大模型GEO内链优化:3个传导逻辑提权重,零成本提29%收录优先级附布局表
人工智能·算法
Risk Actuary7 小时前
手动示例解释机器学习中 GBDT 算法原理
人工智能·算法·机器学习
Lucis__8 小时前
基于Cache替换算法的LRU缓存实现
数据结构·c++·算法·缓存·lru
血色橄榄枝13 小时前
基于用户注册信息的关键词检测挑战赛「Datawhale AI 夏令营」
人工智能·算法·机器学习
c2385614 小时前
第二篇:《测试指挥官:可视化单题自测框架(含 assert 实操)》
java·数据库·c++·算法·安全性测试
六点_dn15 小时前
Linux学习笔记-printf命令
linux·运维·算法
三84416 小时前
路由策略/控制 配置双点双向路由重发布
服务器·前端·javascript
遥感知识服务16 小时前
Sentinel-1 + DEM + FwDET + 随机森林:从快速水深初估到多因子误差修正
算法·随机森林·sentinel