【每日算法】LeetCode 22. 括号生成

对前端开发者而言,学习算法绝非为了"炫技"。它是你从"页面构建者"迈向"复杂系统设计者"的关键阶梯。它将你的编码能力从"实现功能"提升到"设计优雅、高效解决方案"的层面。从现在开始,每天投入一小段时间,结合前端场景去理解和练习,你将会感受到自身技术视野和问题解决能力的质的飞跃。

------ 算法:资深前端开发者的进阶引擎

LeetCode 22. 括号生成:理解回溯与递归的精髓

1. 题目描述

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

示例 1:

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

示例 2:

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

约束:

  • 1 <= n <= 8

2. 问题分析

这是一个典型的组合生成 问题,其核心在于 "有效性" 约束。

对于一个由 n 对括号组成的字符串:

  1. 长度约束 :字符串总长度为 2n
  2. 括号有效性约束
    • 在生成的任何时刻,已使用的右括号 ) 数量 不能超过 已使用的左括号 ( 数量 。否则会形成如 ()) 这样无效的前缀。
    • 最终,左括号和右括号的数量必须相等,都等于 n

理解这个问题,可以想象我们正在递归地构建一棵树 ,每个节点代表一个部分构建的字符串。这个思维方式与前端中构建组件树、处理嵌套路由或解析模板语法的递归过程高度相似。

3. 解题思路

3.1 核心思路:回溯法(DFS)

这是解决此类"生成所有可能组合"问题的最优且最直观 的方法。我们可以把生成过程看作一个 决策树 的深度优先遍历:

  • 选择 :在每个位置,我们可以选择放置左括号 ( 或右括号 )
  • 约束(剪枝条件)
    1. 左括号数量 left 不能超过 n
    2. 右括号数量 right 不能超过左括号数量 left
  • 结束条件 :当字符串长度达到 2n 时,得到一个有效解,将其加入结果列表。

复杂度

  • 时间复杂度:O( (4^n) / sqrt(n) )。这是卡特兰数的渐近复杂度,也是生成所有有效括号组合的理论下界,因此回溯法是最优解。
  • 空间复杂度:O(n) 。主要取决于递归调用栈的深度,最大为 2n。输出结果本身不计入复杂度。

3.2 其他思路:动态规划(递推构造)

思想是:任何一个有效括号串 s 都可以表示为 (a)b 的形式,其中 ab 本身也是有效的括号串(可以为空)。利用这个关系,可以从较小 n 的解递推构造出 n 的解。此方法在思维上更具技巧性,但时间复杂度与回溯法相同。

4. 代码实现

4.1 思路一:回溯法(JavaScript实现)

javascript 复制代码
/**
 * @param {number} n
 * @return {string[]}
 */
var generateParenthesis = function(n) {
    const result = [];
    
    /**
     * 回溯函数
     * @param {string} path - 当前构建的字符串
     * @param {number} left - 已使用的左括号数量
     * @param {number} right - 已使用的右括号数量
     */
    const backtrack = (path, left, right) => {
        // 结束条件:路径长度达到2n
        if (path.length === 2 * n) {
            result.push(path);
            return;
        }
        
        // 选择1:尝试添加左括号(前提:左括号还有剩余)
        if (left < n) {
            backtrack(path + '(', left + 1, right);
        }
        
        // 选择2:尝试添加右括号(前提:右括号数量 < 左括号数量)
        if (right < left) {
            backtrack(path + ')', left, right + 1);
        }
    };
    
    // 从空字符串开始回溯
    backtrack('', 0, 0);
    return result;
};

// 测试
console.log(generateParenthesis(3));
// 输出: ["((()))","(()())","(())()","()(())","()()()"]

4.2 思路二:动态规划(JavaScript实现)

javascript 复制代码
var generateParenthesis = function(n) {
    // dp[i] 存储所有 i 对括号的有效组合
    const dp = new Array(n + 1).fill().map(() => []);
    // 基础情况:0对括号是一个空串
    dp[0] = [''];
    
    // 递推计算 dp[1] 到 dp[n]
    for (let i = 1; i <= n; i++) {
        for (let j = 0; j < i; j++) {
            // dp[j] 对应内层括号组合 a
            // dp[i - 1 - j] 对应外层括号组合 b
            for (const a of dp[j]) {
                for (const b of dp[i - 1 - j]) {
                    // 按照 `(a)b` 的形式构造
                    dp[i].push('(' + a + ')' + b);
                }
            }
        }
    }
    
    return dp[n];
};

5. 实现思路对比

特性/维度 回溯法(DFS) 动态规划(DP)
核心思想 系统地遍历决策树,通过约束条件剪枝。 利用 (a)b 的结构,自底向上递推构造。
时间复杂度 O( (4^n) / sqrt(n) ) O( (4^n) / sqrt(n) )
空间复杂度 O(n) (递归栈) O( (4^n) / sqrt(n) ) (存储所有中间结果)
优点 1. 直观易懂 ,符合人脑的枚举思维。 2. 空间效率高 ,无需存储所有中间状态。 3. 易于剪枝优化,无效路径能及早终止。 1. 体现了问题的最优子结构 。 2. 避免了递归的栈开销(但存储结果开销更大)。
缺点 需要理解递归和回溯的机制。 1. 思维难度稍高 ,需要发现 (a)b 的构造规律。 2. 占用更多内存 存储所有 dp[i] 的结果。
前端场景联想 组件树的递归渲染<Parent><Child /></Parent>,每个组件像括号一样需要正确"打开"和"闭合"。 动态配置生成:根据不同的规则(n)组合出不同的页面布局或表单验证规则集。

6. 总结

算法核心 :LeetCode 22题的精髓在于理解递归(回溯)在构建受限组合时的强大能力,以及剪枝操作对于提升效率的关键作用。这是深度优先搜索(DFS)的一个经典应用。

实际应用场景

  1. 前端AST与语法解析:处理HTML/JSX标签、CSS规则的嵌套匹配,与括号生成原理完全相同。编写一个简单的模板引擎或语法高亮器时,你需要确保所有开标签都有对应的闭标签。
  2. 动态表单与配置生成:例如,根据一组嵌套的规则(类似括号的层级),动态生成一个有多层折叠、条件显示的表单界面。
  3. 路由权限与菜单生成:具有嵌套层级的路由配置或侧边栏菜单,其生成和校验逻辑可以抽象为类似的树形结构遍历问题。
  4. 状态机与用户流程:在复杂的多步骤表单或交互流程中,确保用户的"进入"和"离开"操作符合既定的业务逻辑顺序。

7.附加---回溯步骤分解

n=3 回溯过程详细分解

1. 回溯算法执行流程

我使用下面的状态树来展示回溯过程,其中每个节点的格式为:

  • (路径, 左括号数, 右括号数)
  • 绿色✓表示有效路径,红色✗表示无效路径
  • 终止条件:路径长度=6

('', 0, 0) ('(', 1, 0) ✗ 右括号无效 ('((', 2, 0) ('()', 1, 1) ('(((', 3, 0) ('(()', 2, 1) ('()(', 2, 1) ✗ 右括号无效 ('((()', 3, 1) ✗ 左括号已满 ('(()(', 3, 1) ('(())', 2, 2) ('()((', 3, 1) ('()()', 2, 2) ('((())', 3, 2) ✗ 左括号已满 ('(()()', 3, 2) ✗ 左括号已满 ('(())(', 3, 2) ✗ 右括号无效 ('()(()', 3, 2) ✗ 左括号已满 ('()()(', 3, 2) ✗ 右括号无效 ('((()))', 3, 3) ✓ ✗ 左括号已满 ('(()())', 3, 3) ✓ ✗ 左括号已满 ('(())()', 3, 3) ✓ ✗ 左括号已满 ('()(())', 3, 3) ✓ ✗ 左括号已满 ('()()()', 3, 3) ✓ ✗ 左括号已满

2. 逐步执行分析

2.1 起始状态
复制代码
初始调用: backtrack('', 0, 0)
当前路径: ""
左括号数: 0, 右括号数: 0
2.2 第一次决策
复制代码
条件检查:
1. left=0 < n=3 ✓ → 可以添加'('
2. right=0 < left=0 ✗ → 不能添加')'

执行: backtrack('(', 1, 0)
2.3 主要分支追踪(以最终生成"((()))"为例)

分支A: 路径 -> "((()))"

复制代码
步骤1: ('', 0, 0)
  ↓ 添加'('
步骤2: ('(', 1, 0)
  ↓ 添加'(' (left=1 < 3)
步骤3: ('((', 2, 0)
  ↓ 添加'(' (left=2 < 3)
步骤4: ('(((', 3, 0)
  ↓ 添加')' (left=3已满, right=0 < left=3 ✓)
步骤5: ('((()', 3, 1)
  ↓ 添加')' (right=1 < left=3 ✓)
步骤6: ('((())', 3, 2)
  ↓ 添加')' (right=2 < left=3 ✓)
步骤7: ('((()))', 3, 3) ✓
  路径长度=6 → 添加到结果: ["((()))"]

分支B: 路径 -> "(()())"

复制代码
步骤1: ('', 0, 0)
  ↓ 添加'('
步骤2: ('(', 1, 0)
  ↓ 添加'('
步骤3: ('((', 2, 0)
  ↓ 添加')' (right=0 < left=2 ✓)
步骤4: ('(()', 2, 1)
  ↓ 添加'(' (left=2 < 3 ✓)
步骤5: ('(()(', 3, 1)
  ↓ 添加')' (left已满, right=1 < left=3 ✓)
步骤6: ('(()()', 3, 2)
  ↓ 添加')' (right=2 < left=3 ✓)
步骤7: ('(()())', 3, 3) ✓
  添加到结果: ["((()))", "(()())"]

分支C: 路径 -> "(())()"

复制代码
步骤1: ('', 0, 0) → ('(' → ('(()', 2, 1)
步骤2: ('(()', 2, 1)
  ↓ 添加')' (right=1 < left=2 ✓)
步骤3: ('(())', 2, 2)
  ↓ 添加'(' (left=2 < 3 ✓)
步骤4: ('(())(', 3, 2)
  ↓ 添加')' (left已满, right=2 < left=3 ✓)
步骤5: ('(())()', 3, 3) ✓
  添加到结果: ["((()))", "(()())", "(())()"]

分支D: 路径 -> "()(())"

复制代码
步骤1: ('', 0, 0) → ('(' → ('()', 1, 1)
步骤2: ('()', 1, 1)
  ↓ 添加'(' (left=1 < 3 ✓)
步骤3: ('()(', 2, 1)
  ↓ 添加'(' (left=2 < 3 ✓)
步骤4: ('()((', 3, 1)
  ↓ 添加')' (left已满, right=1 < left=3 ✓)
步骤5: ('()(()', 3, 2)
  ↓ 添加')' (right=2 < left=3 ✓)
步骤6: ('()(())', 3, 3) ✓
  添加到结果: ["((()))", "(()())", "(())()", "()(())"]

分支E: 路径 -> "()()()"

复制代码
步骤1: ('', 0, 0) → ('(' → ('()', 1, 1)
步骤2: ('()', 1, 1)
  ↓ 添加'(' → ('()(', 2, 1)
步骤3: ('()(', 2, 1)
  ↓ 添加')' (right=1 < left=2 ✓)
步骤4: ('()()', 2, 2)
  ↓ 添加'(' (left=2 < 3 ✓)
步骤5: ('()()(', 3, 2)
  ↓ 添加')' (left已满, right=2 < left=3 ✓)
步骤6: ('()()()', 3, 3) ✓
  添加到结果: ["((()))", "(()())", "(())()", "()(())", "()()()"]

3. 关键点分析

3.1 剪枝条件的作用

从状态树可以看到,红色✗标记的无效分支都被及时剪掉了:

  1. 起始时不能添加')'('', 0, 0)right=0 < left=0不成立
  2. 左括号达到上限后不再添加 :如('(((', 3, 0)时不能再加'('
  3. 右括号不能超过左括号 :如('(())', 2, 2)right=2 < left=2不成立
3.2 递归深度分析
  • 最大递归深度 :当路径为"((()))"时,深度为6(对应6次递归调用)
  • 递归调用总数:从状态树看,总共进行了21次有效递归调用
3.3 状态空间搜索

对于n=3:

  • 总搜索空间(不考虑剪枝)理论上为2⁶=64种组合
  • 经过剪枝后,实际只探索了图中的绿色节点,大大减少了搜索量
相关推荐
天真小巫3 小时前
2025.12.18总结
职场和发展
桓琰3 小时前
非线性滤波——基于EKF的INS/GPS松组合算法的研究(直接法|EKF|欧拉角)
算法·matlab·卡尔曼滤波算法
想自律的露西西★3 小时前
js.39. 组合总和
前端·javascript·数据结构·算法
johnny2333 小时前
Raft算法理解
算法
zore_c3 小时前
【数据结构】栈——超详解!!!(包含栈的实现)
c语言·开发语言·数据结构·经验分享·笔记·算法·链表
Chen--Xing3 小时前
LeetCode 15.三数之和
c++·python·算法·leetcode·rust
月明长歌3 小时前
【码道初阶】【Leetcode105&106】用遍历序列还原二叉树:前序+中序、后序+中序的统一套路与“先建哪边”的坑
java·开发语言·数据结构·算法·leetcode·二叉树
iAkuya3 小时前
(leetcode)力扣100 16除自身以外数组的乘积(预处理前项后项积)
数据结构·算法·leetcode
2301_764441333 小时前
Python实现深海声弹射路径仿真
python·算法·数学建模