单词搜索:二维网格中的 DFS 回溯与剪枝优化

一、题目背景

LeetCode 79「单词搜索」是一道经典的二维网格搜索问题。

题目给定一个 m x n 的字符网格 board,以及一个字符串 word,要求判断 word 是否能够在网格中被搜索出来。

搜索规则如下:

  1. 单词必须按照字符顺序依次匹配;

  2. 每一步只能向上下左右四个方向移动;

  3. 同一个单元格不能在一次搜索路径中被重复使用;

  4. 如果存在一条合法路径可以构成 word,返回 true,否则返回 false

例如:

复制代码
board = [
  ['A','B','C','E'],
  ['S','F','C','S'],
  ['A','D','E','E']
]

word = "ABCCED"

可以从左上角的 A 出发:

复制代码
A -> B -> C -> C -> E -> D

因此返回 true


二、问题本质:在二维网格中寻找一条合法路径

这道题的本质不是简单的字符串匹配,而是:

在二维网格中寻找一条长度为 word.length() 的路径,使路径上的字符依次等于 word 中的字符。

由于路径每一步有最多 4 个方向可以选择,所以天然适合使用 深度优先搜索 DFS

同时,由于每个格子在一条路径中不能重复使用,所以搜索过程中还需要维护访问状态,并在搜索失败时撤销选择。

这正是典型的 回溯算法


三、为什么使用 DFS + 回溯?

假设我们当前要匹配 word[index],并且当前位置是 (row, col)

如果:

复制代码
board[row][col] == word[index]

说明当前位置字符匹配成功。

接下来我们需要继续匹配:

复制代码
word[index + 1]

这个字符只能从当前位置的上下左右四个相邻格子中寻找。

因此搜索逻辑可以抽象为:

复制代码
当前位置匹配成功
    标记当前位置已经使用
    向上下左右继续搜索下一个字符
    如果某个方向成功,返回 true
    如果四个方向都失败,撤销当前位置标记
返回 false

这就是 DFS + 回溯的核心框架。


四、回溯过程分析

word = "ABCCED" 为例。

(0, 0)A 开始:

复制代码
A

匹配成功,继续找 B

向右到 (0, 1)

复制代码
A -> B

匹配成功,继续找 C

向右到 (0, 2)

复制代码
A -> B -> C

继续找下一个 C

向下到 (1, 2)

复制代码
A -> B -> C -> C

继续找 E

向下到 (2, 2)

复制代码
A -> B -> C -> C -> E

继续找 D

向左到 (2, 1)

复制代码
A -> B -> C -> C -> E -> D

全部匹配成功,返回 true

如果某一步发现四个方向都走不通,就需要回退到上一个位置,尝试其他方向。这就是"回溯"的含义。


五、关键问题:如何避免重复使用同一个格子?

题目要求:

同一个单元格内的字母不允许被重复使用。

常见做法有两种:

方法一:使用 visited 数组

定义:

复制代码
vector<vector<bool>> visited;

当某个位置被访问时,将其标记为 true

搜索结束后,再恢复为 false

方法二:原地修改 board

由于 board 中只包含大小写英文字母,所以可以临时把访问过的位置改成特殊字符,例如 '#'

搜索结束后再恢复原字符。

例如:

复制代码
char temp = board[row][col];
board[row][col] = '#';

// 搜索四个方向

board[row][col] = temp;

这种方式可以避免额外的 visited 数组,空间更优。


六、基础版代码实现

复制代码
#include <vector>
#include <string>
using namespace std;

class Solution {
public:
    bool exist(vector<vector<char>>& board, string word) {
        int m = board.size();
        int n = board[0].size();

        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                if (dfs(board, word, i, j, 0)) {
                    return true;
                }
            }
        }

        return false;
    }

private:
    bool dfs(vector<vector<char>>& board, string& word, int row, int col, int index) {
        int m = board.size();
        int n = board[0].size();

        if (row < 0 || row >= m || col < 0 || col >= n) {
            return false;
        }

        if (board[row][col] != word[index]) {
            return false;
        }

        if (index == word.size() - 1) {
            return true;
        }

        char temp = board[row][col];
        board[row][col] = '#';

        bool found =
            dfs(board, word, row + 1, col, index + 1) ||
            dfs(board, word, row - 1, col, index + 1) ||
            dfs(board, word, row, col + 1, index + 1) ||
            dfs(board, word, row, col - 1, index + 1);

        board[row][col] = temp;

        return found;
    }
};

七、代码逻辑拆解

1. 枚举起点

复制代码
for (int i = 0; i < m; i++) {
    for (int j = 0; j < n; j++) {
        if (dfs(board, word, i, j, 0)) {
            return true;
        }
    }
}

因为单词可能从任意位置开始,所以需要遍历整个网格,把每一个格子都作为起点尝试搜索。


2. 越界判断

复制代码
if (row < 0 || row >= m || col < 0 || col >= n) {
    return false;
}

如果当前位置超出网格范围,说明路径不合法。


3. 字符匹配判断

复制代码
if (board[row][col] != word[index]) {
    return false;
}

如果当前位置字符与当前需要匹配的字符不同,直接剪枝。


4. 递归终止条件

复制代码
if (index == word.size() - 1) {
    return true;
}

如果已经匹配到单词最后一个字符,并且当前字符也匹配成功,说明整个单词已经找到。


5. 标记当前位置

复制代码
char temp = board[row][col];
board[row][col] = '#';

将当前位置临时标记为 '#',防止后续搜索又走回这个格子。


6. 搜索四个方向

复制代码
bool found =
    dfs(board, word, row + 1, col, index + 1) ||
    dfs(board, word, row - 1, col, index + 1) ||
    dfs(board, word, row, col + 1, index + 1) ||
    dfs(board, word, row, col - 1, index + 1);

只要上下左右任意一个方向可以成功匹配剩余字符,就说明当前路径有效。


7. 恢复现场

复制代码
board[row][col] = temp;

这是回溯算法中非常关键的一步。

因为当前路径搜索结束后,其他路径仍然可能需要使用这个格子,所以必须恢复原字符。

如果不恢复,后续搜索结果会被污染。


八、进阶优化:搜索剪枝

题目进阶要求我们使用剪枝优化搜索效率。

虽然本题数据范围较小:

复制代码
m, n <= 6
word.length <= 15

但是从算法设计角度来看,剪枝非常重要。


剪枝一:如果 word 长度大于网格格子数,直接返回 false

如果单词长度超过网格总格子数,一定无法构造成功。

复制代码
if (word.size() > m * n) {
    return false;
}

剪枝二:统计字符频率

如果 word 中某个字符出现次数,比 board 中该字符出现次数还多,那么一定无法匹配。

例如:

复制代码
board 中只有 1 个 A
word 中需要 3 个 A

这种情况不需要 DFS,直接返回 false


剪枝三:从更稀有的字符开始搜索

如果 word 的首字符在网格中出现很多次,而尾字符出现很少次,那么从首字符开始会产生很多无效分支。

例如:

复制代码
word = "AAAAAZ"

如果 A 很多,Z 很少,那么从 A 开始会尝试大量路径。

由于单词路径可以反向搜索,所以可以将 word 反转,从更稀有的一端开始匹配。

复制代码
if (count[word[0]] > count[word[word.size() - 1]]) {
    reverse(word.begin(), word.end());
}

这个优化在大网格中非常有效。


九、优化版代码实现

复制代码
#include <vector>
#include <string>
#include <algorithm>
using namespace std;

class Solution {
public:
    bool exist(vector<vector<char>>& board, string word) {
        int m = board.size();
        int n = board[0].size();

        if (word.size() > m * n) {
            return false;
        }

        vector<int> count(128, 0);

        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                count[board[i][j]]++;
            }
        }

        for (char c : word) {
            count[c]--;
            if (count[c] < 0) {
                return false;
            }
        }

        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                count[board[i][j]]++;
            }
        }

        if (count[word[0]] > count[word[word.size() - 1]]) {
            reverse(word.begin(), word.end());
        }

        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                if (board[i][j] == word[0]) {
                    if (dfs(board, word, i, j, 0)) {
                        return true;
                    }
                }
            }
        }

        return false;
    }

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

    bool dfs(vector<vector<char>>& board, string& word, int row, int col, int index) {
        if (board[row][col] != word[index]) {
            return false;
        }

        if (index == word.size() - 1) {
            return true;
        }

        int m = board.size();
        int n = board[0].size();

        char temp = board[row][col];
        board[row][col] = '#';

        for (int i = 0; i < 4; i++) {
            int nextRow = row + dirs[i][0];
            int nextCol = col + dirs[i][1];

            if (nextRow < 0 || nextRow >= m || nextCol < 0 || nextCol >= n) {
                continue;
            }

            if (board[nextRow][nextCol] == '#') {
                continue;
            }

            if (dfs(board, word, nextRow, nextCol, index + 1)) {
                board[row][col] = temp;
                return true;
            }
        }

        board[row][col] = temp;
        return false;
    }
};

十、优化版代码说明

优化版代码主要做了三件事。

1. 长度剪枝

复制代码
if (word.size() > m * n) {
    return false;
}

如果单词长度超过格子总数,必然无法搜索成功。


2. 频率剪枝

复制代码
for (char c : word) {
    count[c]--;
    if (count[c] < 0) {
        return false;
    }
}

如果 word 中需要的某个字符数量超过网格中该字符数量,直接返回 false

这类剪枝属于搜索前预处理,可以大幅减少无意义 DFS。


3. 起点方向优化

复制代码
if (count[word[0]] > count[word[word.size() - 1]]) {
    reverse(word.begin(), word.end());
}

如果单词首字符出现次数比尾字符多,那么从尾字符开始搜索分支更少。

例如:

复制代码
word = "AAAAAB"

假如 A 有很多,B 很少,那么反转成:

复制代码
"BAAAAA"

B 开始搜索可以显著降低起点数量。


十一、复杂度分析

设:

复制代码
m = board 的行数
n = board 的列数
L = word 的长度

时间复杂度

最坏情况下,每个格子都可能作为起点。

第一个字符之后,每一步最多向 3 个方向扩展,因为不能立刻回到上一个格子。

所以时间复杂度可以近似表示为:

复制代码
O(m * n * 3^L)

严格来说,第一步最多有 4 个方向,后续由于访问限制,分支数会下降。


空间复杂度

如果使用原地修改 board,不额外使用 visited 数组。

递归深度最多为 L,因此空间复杂度为:

复制代码
O(L)

这部分空间来自递归调用栈。


十二、为什么不是 BFS?

这道题也可以从搜索角度理解为路径查找问题,但并不适合 BFS。

原因是:

  1. 每一条路径都有独立的访问状态;

  2. 同一个格子不能在同一路径中重复使用;

  3. BFS 需要存储大量路径状态,空间开销较大;

  4. DFS 天然适合"走一条路径,不行再回退"的搜索模型。

因此,本题最自然的解法是:

复制代码
DFS + 回溯

而不是 BFS。


十三、常见错误总结

错误一:忘记恢复现场

错误写法:

复制代码
board[row][col] = '#';
// 搜索结束后没有恢复

这样会导致其他搜索路径无法正常使用该格子。

正确写法:

复制代码
char temp = board[row][col];
board[row][col] = '#';

// DFS

board[row][col] = temp;

错误二:没有处理越界

搜索上下左右时必须判断边界,否则会访问非法数组下标。


错误三:重复使用同一个格子

如果不做访问标记,可能出现如下非法路径:

复制代码
A -> B -> A

其中同一个 A 被使用了多次。


错误四:递归终止条件写错

很多人会把终止条件写成:

复制代码
if (index == word.size()) return true;

这种写法也可以,但需要配合不同的递归设计。

在本文代码中,当前层负责匹配 word[index],所以终止条件是:

复制代码
if (index == word.size() - 1) {
    return true;
}

含义是:当前字符已经是最后一个字符,并且已经匹配成功。


十四、总结

LeetCode 79「单词搜索」是一道典型的二维网格 DFS 回溯题。

它的核心思想可以概括为:

复制代码
枚举每一个起点
从起点开始 DFS
每次匹配当前字符
向上下左右搜索下一个字符
使用标记防止重复访问
搜索失败后恢复现场

这道题考查的不只是 DFS,更重要的是对"状态"的理解。

在回溯算法中,每一步选择都会改变当前路径状态;当这条路径失败时,必须撤销选择,让其他路径继续搜索。

因此,本题的关键不是简单地"递归四个方向",而是:

在搜索过程中正确维护路径状态,并在回溯时恢复现场。

最终推荐解法是:

复制代码
DFS + 回溯 + 原地标记 + 字符频率剪枝 + 起点方向优化

这套写法既清晰,也具备较好的性能表现。

相关推荐
吴可可1231 小时前
C++与C#版Teigha样条离散化差异解析
c++·算法·c#
搬砖的小码农_Sky1 小时前
macOS Sequoia上如何安装gcc/g++环境?
c语言·c++·macos
诸葛务农1 小时前
如何用windows自带的录音机录制(内录)电脑播放的音乐
windows·电脑
MC皮蛋侠客1 小时前
C++17 多线程系列(二):共享数据与同步——mutex 与 condition_variable
开发语言·c++·多线程
中议视控1 小时前
网络中控主机控制电脑开关机:开机可以利用网络唤醒和通电自启,Windows关机利用OY-PWCC关机软件,国产麒麟统信等操作系统利用OY-PCI开关机控制卡
网络·windows·电脑
KaMeidebaby1 小时前
卡梅德生物技术快报|抗体的制备与纯化:分子实验实操:番茄 sHSP 重组表达与抗体的制备与纯化工艺
前端·数据库·人工智能·其他·算法·百度·新浪微博
郝学胜-神的一滴1 小时前
中级OpenGL教程 007:解决背面光照异常高光问题
c++·unity·游戏引擎·three.js·opengl·unreal
晚风叙码2 小时前
《C++基础进阶:函数重载、引用、inline与nullptr全解析》
c++
雪度娃娃2 小时前
ASIO异步通信——服务器网络层和逻辑层设计
开发语言·网络·c++·php