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,结果跑出来一堆右括号开头的离谱组合,人都傻了。

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

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

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

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

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

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

相关推荐
2601_956121971 小时前
二分算法(知识点+题目)
c++·算法
kyriewen2 小时前
别再这样写条件渲染了——你的React组件里藏着这5种定时炸弹
前端·javascript·react.js
梦想CAD控件3 小时前
网页端CAD的图形选择、编辑与夹点操作教程
前端·javascript·node.js
月月大王的3D日记3 小时前
Three.js 入门系列(8):六种光源全解析 —— 关灯了,开光!
前端·javascript
AndrewHZ4 小时前
【LLM技术全景】阶段总结:技术原理篇核心知识回顾
人工智能·深度学习·算法·语言模型·大模型·llm·芯片开发
自然 醒4 小时前
v-tooltip自定义指令封装
前端·javascript·vue.js
小星星闪亮登场5 小时前
2026萌新联赛第三场-- (郑州轻工业大学)
数据结构·c++·经验分享·算法·贪心算法·排序算法·深度优先
冻柠檬飞冰走茶5 小时前
PTA基础编程题目集 7-8超速判断(C++语言实现)
开发语言·数据结构·c++·算法
玖玥拾6 小时前
LeetCode 88 合并两个有序数组
算法·leetcode
数据皮皮侠AI6 小时前
上市公司数字供应链金融指数(2010-2024)
大数据·人工智能·算法