LC 22 括号生成:刷完这道题,我终于搞懂回溯剪枝了

你有没有过这种感觉?回溯的模板背得滚瓜烂熟,可一碰到具体题目还是写不出来。

我当初刷这道题的时候就是这样,对着题目愣了半小时,满脑子都是 "这也能用回溯?"

题目说个明白

给你一个数字 n,代表有 n 对括号,要你生成所有合法的括号组合。

合法的标准就两个:一是左右括号总数刚好都是 n;二是任意前缀里,右括号的数量都不能超过左括号。

暴力枚举:我最开始的朴素想法

一开始我想得特别直白。总共 2n 个位置,每个位置要么放左括号,要么放右括号,把所有可能的组合都生成出来,再挨个判断合不合法不就行了?

思路很简单,写起来也顺。先写个递归把所有组合枚举出来,再写个校验函数,碰到合法的就收进结果里。

javascript 复制代码
var generateParenthesis = function(n) {
    const result = [];

    // 递归生成所有可能的括号组合
    const generateAll = (str, pos) => {
        // 字符串长度到2n了,就检查是否合法
        if (pos === 2 * n) {
            if (isValid(str)) {
                result.push(str);
            }
            return;
        }
        // 每个位置先试放左括号
        generateAll(str + '(', pos + 1);
        // 回溯回来再试放右括号
        generateAll(str + ')', pos + 1);
    }

    // 校验当前字符串是不是合法括号
    const isValid = (s) => {
        let balance = 0;
        for (let c of s) {
            if (c === '(') balance++;
            else balance--;
            // 中途右括号比左括号多,直接不合法
            if (balance < 0) return false;
        }
        // 最后左右括号数量要相等
        return balance === 0;
    }

    generateAll('', 0);
    return result;
};

写完跑了下 n=3,结果是对的。但我越看越觉得别扭 ------ 这也太笨了吧?

比如第一个字符就放右括号,明摆着不可能合法,可程序还是会傻乎乎把后面所有位置都填完,最后再校验失败。n 小的时候还好,n=8 的时候,光无效路径就多到离谱,纯纯的浪费性能。

回溯 + 剪枝才是正确打开方式

那能不能不等字符串生成完再判断?在生成的过程中,就把明显走不通的路直接砍掉?

想明白这点之后,思路一下就通了。说白了就两个规则,从源头把非法路径掐死:

  • 只要左括号还没用完,随时可以加左括号
  • 只有当前右括号数量比左括号少的时候,才能加右括号

就这么两个简单的条件,把所有不可能的路径都剪掉了,剩下的走到底就全是合法结果,连最后校验都省了。

咱们就拿 n=2 一步步走一遍,刚好对应后面的数组写法,走完你就彻底明白回溯和剪枝是怎么跑的了。

初始状态:cur 是长度为 4 的空数组,left=0,right=0,从 dfs (0,0) 开始。

  1. 左括号还没用完(0<2),给下标 0+0=0 的位置填 '(',然后递归走 dfs (1, 0)。
  2. 进入 dfs (1,0),左括号还剩名额(1<2),给下标 1+0=1 的位置填 '(',递归走 dfs (2, 0)。
  3. 进入 dfs (2,0),左括号已经用完了,走不了左分支。看右分支:右括号 0 个 < 左括号 2 个,符合条件,给下标 2+0=2 的位置填 ')',递归走 dfs (2, 1)。
  4. 进入 dfs (2,1),左括号还是满的。右括号 1 个 < 左括号 2 个,给下标 2+1=3 的位置填 ')',递归走 dfs (2, 2)。
  5. 进入 dfs (2,2),right 等于 n=2,触发终止条件,把当前 cur 拼成字符串 (()),塞进结果集,然后返回上一层。

到这里第一条路径就走完了,接下来就是回溯的过程。从 dfs (2,2) 退回到 dfs (2,1),右分支也执行完了,再退回到 dfs (2,0),左右分支都走完了,继续退回到 dfs (1,0)。

这时候 dfs (1,0) 的左分支已经跑完,开始跑右分支:6. 右括号 0 个 < 左括号 1 个,符合条件,给下标 1+0=1 的位置填 ')'------ 你看,这里直接就把之前这个位置的 '(' 覆盖掉了,天然就完成了 "撤销选择",不用额外写回溯撤销的代码。然后递归走 dfs (1, 1)。7. 进入 dfs (1,1),左括号还有剩(1<2),给下标 1+1=2 的位置填 '(',递归走 dfs (2, 1)。8. 进入 dfs (2,1),左括号满了,右括号 1 个 < 左括号 2 个,给下标 2+1=3 的位置填 ')',递归走 dfs (2, 2)。9. 再次触发终止条件,cur 拼成字符串 ()(),塞进结果集,然后逐层返回。

等所有递归都退完,结果集里就有了全部合法组合。全程没有任何一条非法路径,只要右括号数量要超过左括号了,这条路直接就被堵死了,连走都不会走。

优化版代码实现

最开始我写的是字符串拼接的版本,后来发现每次拼接都会生成新字符串,有点浪费性能。就改成了数组占位的写法,提前开好固定长度的数组,直接按下标填字符,效率高不少。

javascript 复制代码
var generateParenthesis = function (n) {
    const ans = [];
    // 提前定好数组长度,总共2n个字符,不用频繁扩容
    const cur = Array(n * 2);

    var dfs = function (left, right) {
        // 终止条件:右括号用完就结束
        // 不用额外判断left------我们一直保证右括号<=左括号,右括号满了,左括号肯定也满了
        if (right === n) {
            ans.push(cur.join(''));
            return;
        }

        // 左括号还有剩余,就填左括号
        if (left < n) {
            // left+right 刚好就是当前要填充的位置下标,不用额外维护索引变量
            cur[left + right] = '(';
            dfs(left + 1, right);
        }

        // 右括号数量 < 左括号,才能填右括号
        // 这个条件我一开始写成 right < n,跑出来一堆离谱结果,踩过实坑
        if (right < left) {
            cur[left + right] = ')';
            dfs(left, right + 1);
        }
    }

    dfs(0, 0);
    return ans;
};

复杂度说两句

暴力法时间复杂度是 O (2^(2n) * n),每个位置两种选择,最后校验还要遍历一遍字符串,效率很低。

剪枝后的回溯法,时间复杂度对应第 n 个卡特兰数,量级大概是 4^n / √n,比暴力法高效得多。不用死记这个公式,知道剪枝后砍掉了大量无效路径就行。

空间复杂度都是 O (n),主要是递归栈的深度,优化版多了一个固定长度的数组,整体开销很小。

踩坑小结

这题我踩过两个印象很深的坑。

第一个就是最开始写暴力法的时候,校验函数忘了中途判断 balance < 0,非要等遍历完再看结果。虽然结果没错,但平白多了很多无效计算。

第二个就是写剪枝版的时候,右括号的判断条件写错了。一开始想当然写成 right < n,结果跑出来一堆右括号开头的离谱组合,人都傻了。

后来才反应过来:合法括号的核心,从来不是最后总数相等,而是任何时候右括号都不能比左括号多

这也是这道题最精髓的地方 ------ 回溯的魅力,从来不是把所有路都走一遍,而是在出发前就知道哪些路根本不用走。

其实回溯题真的没那么玄乎。核心就三个问题:能选什么?什么时候不能选?什么时候结束?

把这三个问题想清楚,大部分中等难度的回溯题都能拿下。

你刷这道题的时候有没有踩过什么有意思的坑?或者你有别的更巧妙的解法?评论区聊聊,我每条都会看的~

觉得文章有用的话,点个赞再走呀,谢谢啦。

相关推荐
小二·1 小时前
Next.js 15 企业级实战:从项目搭建到CI/CD全链路(App Router + Server Components + Turbopack)
javascript·ci/cd·turbopack
像我这样帅的人丶你还1 小时前
🚀大文件上传的那些事
前端·javascript·架构
To_OC1 小时前
LC 39 组合总和:回溯入门必刷题,我踩过的两个坑都在这了
javascript·算法·leetcode
数字杂技师1 小时前
每条推荐都很准,为什么我还是越刷越无聊?
算法
兰令水1 小时前
hot100【acm版】【2026.7.14打卡-java版本】
java·数据结构·算法·leetcode·面试
橘子星2 小时前
从零理解流式输出 —— 一个 Vue + DeepSeek 的前端实战
前端·javascript·人工智能
cpp_25012 小时前
P10098 [ROIR 2023] 地铁建设 (Day 2)
数据结构·c++·算法·贪心·二分答案·洛谷题解
এ慕ོ冬℘゜3 小时前
深入 JavaScript BOM:掌握 window 对象的窗口控制魔法
开发语言·javascript·ecmascript
阳明山水3 小时前
TimesFM与Moirai MoE零样本预测解析
人工智能·深度学习·算法·机器学习·架构