Leetcode 140 括号生成 | 单词搜索

1 题目

22. 括号生成

数字 n 代表生成括号的对数,请你设计一个函数,用于能够生成所有可能的并且 有效的括号组合。

示例 1:

复制代码
输入:n = 3
输出:["((()))","(()())","(())()","()(())","()()()"]

示例 2:

复制代码
输入:n = 1
输出:["()"]

提示:

  • 1 <= n <= 8

2 代码实现

c++

cpp 复制代码
class Solution {
private:
    void backtrack(int left ,int right , string &current , vector<string> &result ){
        if (left == 0 && right == 0){
            result.push_back(current);
            return ;
        }

        if (left > 0 ){
            current.push_back('(');
            backtrack(left - 1 , right , current , result);
            current.pop_back();
        }

        if (right > left ){
            current.push_back(')');
            backtrack(left , right - 1 ,current,result);
            current.pop_back();
        }
    }
public:
    vector<string> generateParenthesis(int n) {
        vector<string> result ;
        string current ;
        backtrack(n,n,current,result);
        return result;
        
    }
};

思考

。。。这是什么,为什么一点思路都没有啊,用回溯,这样怎么配对好有效的括号呢?还要用到栈配对吗,怎么做啊一点都不会,选择 - 撤销,然后呢?怎么做啊?

看了题解,有关配对的在左右括号加入的时候就做好逻辑,有左括号了,才能顺着加一个右括号(也就是当前兜里右括号比左括号多)

题解

别着急!括号生成这个问题确实是回溯算法的经典应用,刚开始接触会觉得无从下手很正常。我会一步步拆解思路,再给出完整的 C++ 代码,保证你能看懂。

核心思路分析

要生成有效的括号组合,关键要遵守两个规则:

  1. 左括号规则 :生成过程中,左括号的数量不能超过给定的对数 n
  2. 右括号规则 :任何时刻,右括号的数量不能超过左括号的数量(否则就会出现 ()) 这种无效组合)

回溯的核心就是:做选择 → 递归 → 撤销选择

  • 选择:加左括号 或 加右括号(必须满足上面的规则)
  • 递归:继续构建括号字符串
  • 撤销:删掉刚加的括号,尝试另一种选择

不需要用栈来验证有效性,我们可以在生成过程中就保证有效性,这是更高效的做法。

完整 C++ 代码实现

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

class Solution {
public:
    vector<string> generateParenthesis(int n) {
        vector<string> result;  // 存储最终结果
        string current;         // 存储当前正在构建的括号字符串
        // 回溯函数:参数分别是 剩余左括号数、剩余右括号数、当前字符串、结果集
        backtrack(n, n, current, result);
        return result;
    }

private:
    void backtrack(int left, int right, string& current, vector<string>& result) {
        // 递归终止条件:左右括号都用完了,说明生成了一个有效组合
        if (left == 0 && right == 0) {
            result.push_back(current);
            return;
        }

        // 选择1:添加左括号(前提:还有剩余的左括号)
        if (left > 0) {
            current.push_back('(');          // 做选择:加左括号
            backtrack(left - 1, right, current, result);  // 递归
            current.pop_back();              // 撤销选择:删掉最后加的左括号
        }

        // 选择2:添加右括号(前提:剩余右括号数 > 剩余左括号数,保证有效性)
        if (right > left) {
            current.push_back(')');          // 做选择:加右括号
            backtrack(left, right - 1, current, result);  // 递归
            current.pop_back();              // 撤销选择:删掉最后加的右括号
        }
    }
};

// 测试代码
int main() {
    Solution s;
    int n = 3;
    vector<string> res = s.generateParenthesis(n);
    
    cout << "n = " << n << " 时的有效括号组合:" << endl;
    for (const string& str : res) {
        cout << str << " ";
    }
    cout << endl;

    // 测试n=1的情况
    n = 1;
    res = s.generateParenthesis(n);
    cout << "n = " << n << " 时的有效括号组合:" << endl;
    for (const string& str : res) {
        cout << str << " ";
    }
    cout << endl;

    return 0;
}

代码关键部分解释

  1. 递归函数参数

    • left:剩余可用的左括号数量
    • right:剩余可用的右括号数量
    • current:当前正在构建的括号字符串(引用传递,避免拷贝)
    • result:存储所有有效组合的结果集(引用传递)
  2. 递归终止条件 :当 leftright 都为 0 时,说明已经生成了一个完整且有效的括号组合,加入结果集。

  3. 选择左括号 :只有当 left > 0 时才能加左括号(左括号没用完),加完后递归(left-1),递归返回后撤销选择(pop_back)。

  4. 选择右括号 :只有当 right > left 时才能加右括号(保证右括号不超过左括号数量),加完后递归(right-1),递归返回后撤销选择

执行流程示例(n=2)

bash 复制代码
初始状态:left=2, right=2, current=""
1. 加左括号 → current="(", left=1, right=2
   a. 加左括号 → current="((", left=0, right=2
      - 加右括号 → current="(()", left=0, right=1
        - 加右括号 → current="(())" → 加入结果集
      - 撤销右括号 → current="(("
   b. 撤销左括号 → current="("
   c. 加右括号 → current="()", left=1, right=1
      - 加左括号 → current="()(", left=0, right=1
        - 加右括号 → current="()()" → 加入结果集
      - 撤销左括号 → current="()"
2. 撤销左括号 → current=""
最终结果:["(())", "()()"]

总结

  1. 核心规则:生成有效括号的关键是「左括号不超 n,右括号不超左括号」,在生成过程中直接保证有效性,无需事后验证。
  2. 回溯逻辑:先尝试加左括号(满足条件时),递归到底后回溯;再尝试加右括号(满足条件时),递归到底后回溯。
  3. 效率优势:这种做法不会生成无效组合,比「生成所有组合再验证」的方式效率高得多(n=8 时尤为明显)。

3 题目

79. 单词搜索

给定一个 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
  • boardword 仅由大小写英文字母组成

进阶: 你可以使用搜索剪枝的技术来优化解决方案,使其在 board 更大的情况下可以更快解决问题?

4 代码实现

思考

蛤????????什么鬼啊,这个不是图吗。这样要怎么做啊??!!!

题解

79. 单词搜索 超详细题解(新手友好版)

这道题是二维网格 + 回溯(DFS) 的经典题,刚开始学确实会觉得难,但只要把 "走迷宫" 的逻辑拆解开,就会发现它和你之前学的括号生成本质是一样的 ------ 都是「做选择→递归探索→撤销选择」。我会从题意拆解→核心思路→代码逐行解释→执行流程模拟 一步步讲,保证你能看懂。

一、题意拆解(先把问题说人话)

题目要求

给定一个 m×n 的字符网格 board 和一个字符串 word,判断:

  1. 能否从网格中任意位置出发;
  2. 沿着上下左右相邻的单元格走;
  3. 每个单元格只能用一次
  4. 按顺序拼出整个 word。能拼出来返回 true,否则返回 false

示例直观理解

比如示例 3 中 board = [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]]word = "ABCB"

  • 路径 A(0,0)→B(0,1)→C(0,2) 后,下一步要找 B
  • 周围只有 B(0,1) 是目标,但这个位置已经用过了,所以拼不出来,返回 false

二、核心思路(用 "走迷宫" 类比)

想象你在网格里 "走迷宫" 找单词,整个过程分两步:

第一步:找起点

遍历网格的每一个位置 (i,j),如果 board[i][j] 等于 word 的第一个字母,就把这个位置当作 "迷宫起点"。

第二步:从起点开始 DFS(深度优先搜索)走迷宫

DFS 的核心是「试错 + 回退」,规则如下:

  1. 当前步:检查当前位置是否合法(没走过 + 字母匹配);
  2. 做选择:标记当前位置 "已走过"(避免重复踩);
  3. 探下一步:往上下左右四个方向走,递归检查下一个字母;
  4. 回退(回溯):如果四个方向都走不通,取消 "已走过" 标记,回到上一步试其他方向;
  5. 终止条件:如果走到了单词的最后一个字母,说明找到路径了。

三、代码逐行解释(新手级注释)

先贴完整代码(带极致详细注释),再逐模块拆解:

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

class Solution {
public:
    // 定义四个移动方向:上、下、左、右(用二维数组简化方向遍历)
    // dirs[0] = {-1,0} → 行-1,列不变(向上走)
    // dirs[1] = {1,0} → 行+1,列不变(向下走)
    // dirs[2] = {0,-1} → 列-1,行不变(向左走)
    // dirs[3] = {0,1} → 列+1,行不变(向右走)
    vector<vector<int>> dirs = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};

    // 主函数:对外暴露的接口,判断单词是否存在
    bool exist(vector<vector<char>>& board, string word) {
        int m = board.size();    // 网格的行数
        int n = board[0].size(); // 网格的列数

        // 剪枝优化1:如果单词长度超过网格总字符数,直接返回false(不可能匹配)
        if (word.size() > m * n) return false;

        // 第一步:遍历网格所有位置,找可能的起点
        for (int i = 0; i < m; ++i) {          // 遍历每一行
            for (int j = 0; j < n; ++j) {      // 遍历每一列
                // 找到和单词第一个字母匹配的位置,作为起点开始DFS
                if (board[i][j] == word[0]) {
                    // 创建visited数组:标记位置是否被访问过,初始全为false
                    vector<vector<bool>> visited(m, vector<bool>(n, false));
                    // 调用DFS函数,只要有一个起点能找到路径,就返回true
                    if (dfs(board, word, visited, i, j, 0)) {
                        return true;
                    }
                }
            }
        }
        // 所有起点都试完了,没找到路径
        return false;
    }

private:
    // DFS递归函数:核心探索逻辑
    // 参数说明:
    // board → 字符网格(引用传递,避免拷贝)
    // word → 要找的单词(引用传递)
    // visited → 访问标记数组(引用传递)
    // x,y → 当前探索的网格位置(行x,列y)
    // index → 当前要匹配的单词下标(比如index=0是第一个字母,index=word.size()-1是最后一个)
    bool dfs(vector<vector<char>>& board, string& word, vector<vector<bool>>& visited, 
             int x, int y, int index) {
        // 第一步:合法性检查(优先级最高!)
        // 1. visited[x][y] == true → 这个位置已经走过了,不能再走
        // 2. board[x][y] != word[index] → 当前位置字母和目标字母不匹配
        // 满足任意一个,直接返回false(这条路走不通)
        if (visited[x][y] || board[x][y] != word[index]) {
            return false;
        }

        // 第二步:终止条件(找到完整路径)
        // 如果当前匹配的是单词最后一个字母(index到末尾了),且上面的合法性检查通过
        // 说明已经拼出整个单词,返回true
        if (index == word.size() - 1) {
            return true;
        }

        // 第三步:做选择(标记当前位置为已访问)
        // 避免后续递归中重复访问这个位置
        visited[x][y] = true;

        // 第四步:遍历四个方向,探索下一个字母
        for (auto& dir : dirs) {  // 依次尝试上、下、左、右四个方向
            int nx = x + dir[0];  // 新位置的行坐标(当前行+方向行偏移)
            int ny = y + dir[1];  // 新位置的列坐标(当前列+方向列偏移)
            
            // 检查新坐标是否在网格范围内(不能走出网格)
            // nx >= 0 → 行不越上界;nx < board.size() → 行不越下界
            // ny >= 0 → 列不越左界;ny < board[0].size() → 列不越右界
            if (nx >= 0 && nx < board.size() && ny >= 0 && ny < board[0].size()) {
                // 递归探索下一个字母(index+1),只要有一个方向找到路径,就返回true
                if (dfs(board, word, visited, nx, ny, index + 1)) {
                    return true;
                }
            }
        }

        // 第五步:回溯(撤销选择)
        // 四个方向都试完了,都走不通 → 取消当前位置的访问标记
        // 让这个位置可以被其他路径使用(比如上一步换个方向走)
        visited[x][y] = false;

        // 所有方向都探索过,没找到路径,返回false
        return false;
    }
};

// 测试代码:可以直接运行,验证示例
int main() {
    Solution s;
    // 示例网格
    vector<vector<char>> board = {
        {'A','B','C','E'},
        {'S','F','C','S'},
        {'A','D','E','E'}
    };
    
    // 测试用例1:ABCCED → 预期true
    string word1 = "ABCCED";
    cout << "ABCCED: " << (s.exist(board, word1) ? "true" : "false") << endl;
    
    // 测试用例2:SEE → 预期true
    string word2 = "SEE";
    cout << "SEE: " << (s.exist(board, word2) ? "true" : "false") << endl;
    
    // 测试用例3:ABCB → 预期false
    string word3 = "ABCB";
    cout << "ABCB: " << (s.exist(board, word3) ? "true" : "false") << endl;

    return 0;
}

模块 1:方向数组(核心简化技巧)

cpp 复制代码
vector<vector<int>> dirs = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
  • 作用:把 "上下左右" 四个方向转换成数组,避免写 4 次重复的if判断;
  • 用法:遍历dirs,每次取一个方向dir,计算新坐标nx = x+dir[0]ny = y+dir[1]

模块 2:主函数exist(找起点)

  1. 剪枝优化 :先判断单词长度是否超过网格总字符数,超过直接返回false(比如网格只有 6 个字符,单词有 7 个,不可能匹配);
  2. 遍历网格 :逐行逐列找和word[0]匹配的位置,作为 DFS 的起点;
  3. 调用 DFS :每个起点调用一次 DFS,只要有一个返回true,整体就返回true

模块 3:DFS 函数(核心探索逻辑)

这是整个题的关键,拆成 5 步理解:

步骤 1:合法性检查(最优先)

必须先检查「是否已访问」和「字母是否匹配」,这是避免错误的核心 ------ 比如示例 3 中,走到C(0,2)后找B,会先检查B(0,1)是否已访问(是),直接返回false,不会误判。

步骤 2:终止条件(找到完整路径)

只有当index到单词最后一位,且合法性检查通过时,才说明拼出了整个单词,返回true

步骤 3:做选择(标记已访问)

visited[x][y]设为true,防止后续递归重复访问这个位置(比如走回头路)。

步骤 4:遍历方向(探索下一步)
  • 遍历四个方向,计算新坐标nx, ny
  • 先检查新坐标是否在网格内(避免越界);
  • 递归调用 DFS,匹配下一个字母(index+1);
  • 只要有一个方向返回true,就立刻返回true(提前终止,不浪费时间)。
步骤 5:回溯(撤销选择)

如果四个方向都走不通,把visited[x][y]改回false------ 比如从A(0,0)走到B(0,1),发现走不通,就取消B(0,1)的标记,回到A(0,0)试其他方向(虽然A(0,0)只有右方向,但复杂网格里会有多个方向可选)。

四、执行流程模拟(以示例 3:ABCB 为例)

我们一步步走word = "ABCB"的执行过程,理解为什么返回false

第一步:找起点

网格中A(0,0)word[0](A)匹配,作为起点,调用dfs(board, "ABCB", visited, 0, 0, 0)

第二步:DFS (0,0,0)(匹配 A)

  1. 合法性检查:visited[0][0]=falseboard[0][0] = A == word[0],通过;
  2. 终止条件:index=0 != 3(单词长度 4,最后一位是 3),不触发;
  3. 做选择:visited[0][0] = true
  4. 遍历四个方向:
    • 上:nx=-1(越界),跳过;
    • 下:nx=1, ny=0(S),board[1][0] != B(word [1]),DFS 返回false
    • 左:ny=-1(越界),跳过;
    • 右:nx=0, ny=1(B),调用dfs(0,1,1)

第三步:DFS (0,1,1)(匹配 B)

  1. 合法性检查:visited[0][1]=falseboard[0][1] = B == word[1],通过;
  2. 终止条件:index=1 !=3,不触发;
  3. 做选择:visited[0][1] = true
  4. 遍历四个方向:
    • 上:越界,跳过;
    • 下:nx=1, ny=1(F),F != C(word [2]),返回false
    • 左:nx=0, ny=0(A),visited[0][0]=true,返回false
    • 右:nx=0, ny=2(C),调用dfs(0,2,2)

第四步:DFS (0,2,2)(匹配 C)

  1. 合法性检查:visited[0][2]=falseboard[0][2] = C == word[2],通过;
  2. 终止条件:index=2 !=3,不触发;
  3. 做选择:visited[0][2] = true
  4. 遍历四个方向:
    • 上:越界,跳过;
    • 下:nx=1, ny=2(C),C != B(word [3]),返回false
    • 左:nx=0, ny=1(B),visited[0][1]=true,返回false
    • 右:nx=0, ny=3(E),E != B,返回false
  5. 回溯:visited[0][2] = false,返回false

第五步:回溯 + 返回

  • dfs(0,2,2)返回false → 回到dfs(0,1,1),四个方向都走完,回溯visited[0][1]=false,返回false
  • dfs(0,1,1)返回false → 回到dfs(0,0,0),四个方向都走完,回溯visited[0][0]=false,返回false
  • 主函数中A(0,0)这个起点返回false,遍历其他位置(没有其他 A),最终返回false

五、新手常见问题解答

1. 为什么要回溯(撤销 visited 标记)?

比如网格是[["A","B"],["C","D"]],单词是"ABCD"

  • A(0,0)B(0,1),发现走不通,撤销B的标记;
  • 回到A,试下方向C(1,0),再走D(1,1),就能找到路径;
  • 如果不撤销B的标记,后续其他路径也无法使用B,会漏掉正确路径。

2. 为什么方向数组要这么定义?

  • 行号x:向上走行号减 1(x-1),向下走行号加 1(x+1);
  • 列号y:向左走列号减 1(y-1),向右走列号加 1(y+1);
  • 数组{行偏移, 列偏移}刚好对应这个规则,是行业通用写法。

3. 能不能不用 visited 数组?

可以!用原地标记优化(省空间):

  • 做选择:把board[x][y]改成特殊字符(比如'#');
  • 回溯:把board[x][y]改回原来的字符;
  • 优点:不用额外创建visited数组,空间复杂度更低;
  • 注意:必须用引用传递board,且回溯时恢复原值。

示例修改(DFS 函数内):

cpp 复制代码
// 替代visited的写法
bool dfs(...) {
    // 合法性检查:用board[x][y] == '#'判断是否访问过
    if (board[x][y] == '#' || board[x][y] != word[index]) {
        return false;
    }

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

    // 做选择:标记为已访问
    char temp = board[x][y]; // 保存原字符
    board[x][y] = '#';

    // 遍历方向...

    // 回溯:恢复原字符
    board[x][y] = temp;

    return false;
}

六、总结(核心要点)

  1. 核心逻辑:单词搜索 = 「遍历起点 + 四向 DFS 回溯」,和括号生成的回溯本质一致(选择→递归→撤销);
  2. 关键顺序:DFS 中必须先做「合法性检查」,再判断「终止条件」,否则会出现重复访问的错误;
  3. 简化技巧:用方向数组遍历四个方向,避免重复代码;
  4. 优化思路:剪枝(提前终止、预判长度)+ 原地标记(省空间)。

5 小结

没学会啊没学会,感觉太难了,回头再看看,/(ㄒoㄒ)/。。。。

相关推荐
zhengzhengwang2 小时前
chrome v8 内存管理机制
jvm·chrome·算法
njsgcs2 小时前
空间中最后一条折弯线垂直于第一条折弯线
算法
qq_404265832 小时前
C++中的代理模式实战
开发语言·c++·算法
玛卡巴卡ldf2 小时前
【LeetCode 手撕算法】(滑动窗口) 3-无重复字符的最长子串、438-找到字符串中所有字母异位词
数据结构·算法·leetcode·哈希算法
liuyao_xianhui2 小时前
动态规划_最大子数组和_C++
java·开发语言·数据结构·c++·算法·链表·动态规划
故事和你9111 小时前
sdut-程序设计基础Ⅰ-实验五一维数组(8-13)
开发语言·数据结构·c++·算法·蓝桥杯·图论·类和对象
像污秽一样11 小时前
算法与设计与分析-习题4.2
算法·排序算法·深度优先·dfs·bfs
Storynone12 小时前
【Day20】LeetCode:39. 组合总和,40. 组合总和II,131. 分割回文串
python·算法·leetcode
明明如月学长12 小时前
AI 更新太快学不过来?我用OpenClaw打造专属AI学习工作流
算法