【LeetCode Hot100 刷题日记 (60/100)】79. 单词搜索 —— 回溯算法🧠

6 阅读5分钟

📌 题目链接:79. 单词搜索 - 力扣(LeetCode)

🔍 难度:中等 | 🏷️ 标签:回溯、深度优先搜索(DFS)、矩阵、字符串

⏱️ 目标时间复杂度:O(MN·3^L) (M,N 为 board 尺寸,L 为 word 长度)

💾 空间复杂度:O(MN) (visited 数组 + 递归栈)


在 LeetCode Hot100 的第一站,我们迎来一道经典的回溯+DFS题目——单词搜索(Word Search) 。这道题不仅是对 DFS 和回溯思想的绝佳训练,也是面试中高频出现的“路径类”问题模板。

无论你是准备校招还是社招,掌握本题所体现的状态标记 + 剪枝 + 路径还原技巧,将为你打开更多二维网格搜索类问题的大门!


🔍 题目分析

给定一个 m x n 的字符矩阵 board 和一个字符串 word,判断该单词是否能由矩阵中相邻(上下左右)且不重复使用的字母按顺序构成。

关键约束:

  • 路径不能回头:同一个格子只能用一次。
  • 起点任意:可以从任意位置开始匹配。
  • 必须连续匹配:字母顺序必须严格对应 word

📌 典型场景:像“找单词游戏”一样,在字母矩阵中“画出”目标单词。


⚙️ 核心算法及代码讲解

本题的核心是 回溯算法(Backtracking) ,本质是一种带状态恢复的深度优先搜索(DFS)

✅ 为什么用回溯?

  • 需要尝试所有可能的路径 → 暴力枚举
  • 路径有状态(已访问格子)→ 需要标记与撤销
  • 一旦某条路径失败,需退回上一步尝试其他方向 → 回溯

🧩 核心函数设计

我们定义一个递归函数:

bool check(board, visited, i, j, word, k)

含义:(i, j) 出发,能否匹配 word 从第 k 个字符开始的后缀?

执行逻辑(逐行注释):

// 如果当前格子字符 ≠ word[k],直接失败
if (board[i][j] != s[k]) return false;

// 如果已经匹配到最后一个字符,成功!
else if (k == s.length() - 1) return true;

// 标记当前格子为已访问(防止重复使用)
visited[i][j] = true;

// 定义四个方向:右、左、下、上
vector<pair<int, int>> directions{{0, 1}, {0, -1}, {1, 0}, {-1, 0}};

bool result = false;
for (const auto& dir : directions) {
    int newi = i + dir.first, newj = j + dir.second;
    // 边界检查 + 是否未访问
    if (newi >= 0 && newi < board.size() && 
        newj >= 0 && newj < board[0].size() && 
        !visited[newi][newj]) {
        
        // 递归尝试下一个字符
        bool flag = check(board, visited, newi, newj, s, k + 1);
        if (flag) {
            result = true;
            break; // 找到一条可行路径即可提前退出(剪枝)
        }
    }
}

// 【关键!】回溯:撤销当前格子的访问标记,供其他路径使用
visited[i][j] = false;
return result;

💡 注意visited 的“标记-递归-撤销”三步是回溯的灵魂!


🧭 解题思路(分步骤)

  1. 外层遍历所有起点

    • 遍历 board 的每个 (i, j),只要 board[i][j] == word[0],就尝试从此处开始 DFS。
  2. 内层 DFS + 回溯

    • 使用 visited 数组记录当前路径已使用的格子。
    • 每次进入新格子前检查:是否越界?是否已访问?是否字符匹配?
    • 若匹配到最后一个字符,返回 true
    • 否则向四个方向递归,任一方向成功即返回 true
  3. 剪枝优化

    • 一旦某方向找到答案,立即 break,避免无谓搜索。
    • 字符不匹配或越界时直接返回,不继续递归。
  4. 状态恢复

    • 递归返回后,必须将 visited[i][j] = false,否则会影响其他起点的搜索。

📊 算法分析

项目分析
时间复杂度最坏情况:每个起点尝试 3^L 条路径(首步4方向,后续最多3方向),共 MN 个起点 → O(MN·3^L) 。但实际因剪枝远低于此。
空间复杂度visited 数组:O(MN);递归栈深度:O(min(L, MN)) → 总 O(MN)
是否可优化?✅ 可加入预剪枝: 1. 统计 word 中各字符频次,若 board 中某字符不足,直接返回 false。 2. 若 word 首尾字符在 board 中出现次数不同,可反转 word 从更少的起点开始(减少无效搜索)。

🎯 面试加分点:主动提出上述两种优化,展现工程思维!


💻 代码

✅ C++

#include <bits/stdc++.h>
using namespace std;
using ll = long long;

class Solution {
public:
    bool check(vector<vector<char>>& board, vector<vector<int>>& visited, int i, int j, string& s, int k) {
        // 当前字符不匹配,直接失败
        if (board[i][j] != s[k]) {
            return false;
        } 
        // 已匹配到最后一个字符,成功!
        else if (k == s.length() - 1) {
            return true;
        }
        // 标记当前位置为已访问
        visited[i][j] = true;
        // 四个方向:右、左、下、上
        vector<pair<int, int>> directions{{0, 1}, {0, -1}, {1, 0}, {-1, 0}};
        bool result = false;
        for (const auto& dir: directions) {
            int newi = i + dir.first, newj = j + dir.second;
            // 边界检查 + 未访问检查
            if (newi >= 0 && newi < board.size() && newj >= 0 && newj < board[0].size()) {
                if (!visited[newi][newj]) {
                    // 递归匹配下一个字符
                    bool flag = check(board, visited, newi, newj, s, k + 1);
                    if (flag) {
                        result = true;
                        break; // 找到一条路径即可,剪枝
                    }
                }
            }
        }
        // 【回溯】撤销访问标记,供其他路径使用
        visited[i][j] = false;
        return result;
    }

    bool exist(vector<vector<char>>& board, string word) {
        int h = board.size(), w = board[0].size();
        // 初始化 visited 数组(全0)
        vector<vector<int>> visited(h, vector<int>(w));
        // 遍历所有可能的起点
        for (int i = 0; i < h; i++) {
            for (int j = 0; j < w; j++) {
                bool flag = check(board, visited, i, j, word, 0);
                if (flag) {
                    return true; // 找到即返回
                }
            }
        }
        return false;
    }
};

// 测试
signed main(){
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    cout.tie(nullptr);

    Solution sol;
    
    // 示例1
    vector<vector<char>> board1 = {{'A','B','C','E'},{'S','F','C','S'},{'A','D','E','E'}};
    cout << sol.exist(board1, "ABCCED") << "\n"; // 输出: 1 (true)
    
    // 示例2
    cout << sol.exist(board1, "SEE") << "\n";    // 输出: 1
    
    // 示例3
    cout << sol.exist(board1, "ABCB") << "\n";   // 输出: 0 (false)

    return 0;
}

✅ JavaScript

var exist = function(board, word) {
    const h = board.length, w = board[0].length;
    const directions = [[0, 1], [0, -1], [1, 0], [-1, 0]];
    const visited = new Array(h);
    for (let i = 0; i < visited.length; ++i) {
        visited[i] = new Array(w).fill(false);
    }
    
    const check = (i, j, s, k) => {
        if (board[i][j] != s.charAt(k)) {
            return false;
        } else if (k == s.length - 1) {
            return true;
        }
        visited[i][j] = true;
        let result = false;
        for (const [dx, dy] of directions) {
            let newi = i + dx, newj = j + dy;
            if (newi >= 0 && newi < h && newj >= 0 && newj < w) {
                if (!visited[newi][newj]) {
                    const flag = check(newi, newj, s, k + 1);
                    if (flag) {
                        result = true;
                        break;
                    }
                }
            }
        }
        visited[i][j] = false; // 回溯
        return result;
    };

    for (let i = 0; i < h; i++) {
        for (let j = 0; j < w; j++) {
            const flag = check(i, j, word, 0);
            if (flag) {
                return true;
            }
        }
    }
    return false;
};

🌟 结语

🌟 本期完结,下期见!🔥

👉 点赞收藏加关注,新文更新不迷路。关注专栏【算法】LeetCode Hot100刷题日记,持续为你拆解每一道热题的底层逻辑与面试技巧!

💬 欢迎留言交流你的解法或疑问!一起进步,冲向 Offer!💪

📌 记住:当你在刷题时,不要只看答案,要像写这篇文章一样,深入思考每一步背后的原理、优化空间和面试价值。这才是真正提升算法能力的方式!