news 2026/10/3 7:33:02

括号生成:回溯的三步,一步都不能省

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
括号生成:回溯的三步,一步都不能省

括号生成是回溯法的入门题,完整解法二十来行,回溯该有的动作它一个不少。

把这套动作嚼透了,后面的子集、排列、组合,走的都是同一套骨架。

把"有效"两个字拆开

题目只提了一句要求:

生成所有可能的并且有效的括号组合。

前半句好办,把2n个字符排成一排就行,难点全在"有效"两个字上。

n = 3时有 3 个左括号和 3 个右括号,随便排的话是C(6,3) = 20种排法。

可答案只有 5 个。

剩下 15 种犯的是同一个错:

从左往右读到某个位置时,右括号出现的次数已经超过了左括号。

比如")("这样的开头,第一个字符是右括号,它前面没有任何左括号,这个括号永远配不上对。

所以"有效"落在一条很朴素的规则上:

从左往右读,读到的任何一刻,已经出现的)都不能比(多。

记住这一条,往后每放一个字符,能不能放,用它一查就知道。

把拼串想成走岔路口

为了讲得顺,下面把"拼字符串"说成"在一块白板上一个字一个字地写"。

规则一个字没改,只是叫法变了。

白板上写着正在拼的那个串。

手上还有两个计数器,一个记已经写了几个(,一个记已经写了几个)。

每走到一个岔路口,面前只有两个出口:

出口干什么什么时候能走
A往后接一个(左括号还没用完
B往后接一个)右括号数量比左括号少

出口 B 的条件,就是那条规则的代码版。

“右括号比左括号少"和"读到的任何一刻右括号的数量不超过左括号”,说的是同一件事。

按这两个出口一路走下去,一条路走到头,就是白板上写满2n个字符。

这时候手上的串一定合法,收进答案。

三个变量,两个出口

落到代码上,整个过程一直握着三样东西:

变量含义
cur正在拼的串
open已经写了几个(
close已经写了几个)

每走到一个岔路口,两个出口对应两条判断:

if(open<max){...}// 出口 A:左括号还没用完if(close<open){...}// 出口 B:右括号不能比左括号多

两条判断都不成立的时候,说明左括号已经用完,右括号也已经和左括号一样多,串正好写满,这条路走到了头。

手画一遍 n = 2 的决策树

max = 2,写满 4 个字符就算一条路走完。

每个节点后面标的是(open, close)。

每一步都在做同一件事。

看看两个出口哪个能走,能走就走出去,走完再退回来。

  • 从"(("出发:open = 2 = max,出口 A 走不了;close = 0 < 2,还能走出口 B,得到"(()"
  • 从"()"出发:open = 1 < 2,可以接(,得到"()("
  • 从"()("出发:open = 2,出口 A 走不了;close = 1 < 2,接上),得到"()()",长度到了 4,收进答案

结果["(())", "()()"],和n = 2的标准答案一致。

n = 3时规则完全不变,只是目标长度变成6,走到头的路正好 5 条,就是示例里那 5 个串。

"擦掉最后一笔"是干嘛的

cur.append('(');backtrack(ans,cur,open+1,close,max);cur.deleteCharAt(cur.length()-1);// ← 这一行

全题最容易被忽略、又最不能少的就是这一行。

关键在于,cur** 是外面传进来的同一个对象,整棵树共用一份**,每层递归拿到的都是它本身。

回到白板上写字的比方:

拿一支笔,走完一条路要退回路口去试另一条路,就得把刚写的那一笔擦掉,白板才能恢复成刚走到路口时的样子。

不擦的话,白板上的字只增不减,一路膨胀下去,长度很快超过2n。

而结束条件写的是"长度等于2n",是个相等判断,长度一旦越过这个数就再也不会相等,后面的路永远等不到收答案的那一刻。

所以"加一步、递归、撤一步"三个动作绑在一起,缺一不可。

翻译成 Java 代码

前面讲过的做法,写成代码就是这么几行:

classSolution{publicList<String>generateParenthesis(intn){List<String>ans=newArrayList<String>();// 从空串、两个计数器归零开始搜backtrack(ans,newStringBuilder(),0,0,n);returnans;}// ans 装答案// cur 正在拼的串(全树共用同一个对象)// open 已经用了几个 '('// close 已经用了几个 ')'// max 就是 n,一共要几对publicvoidbacktrack(List<String>ans,StringBuildercur,intopen,intclose,intmax){// 位置填满了,2n 个字符全用完,这时候的串一定合法if(cur.length()==max*2){ans.add(cur.toString());// 存一份快照,不是存这个对象的引用return;}// 出口 A:左括号还有剩,可以放一个if(open<max){cur.append('(');backtrack(ans,cur,open+1,close,max);cur.deleteCharAt(cur.length()-1);// 撤销这一步}// 出口 B:右括号比左括号少,才能放,否则会出现非法前缀if(close<open){cur.append(')');backtrack(ans,cur,open,close+1,max);cur.deleteCharAt(cur.length()-1);// 撤销这一步}}}
代码大白话
cur.length() == max * 22n 个位置填满了,手上这条路径走完了
ans.add(cur.toString())存快照
open < max左括号还没用完
close < open右括号不能比左括号多
cur.append(...)在串的尾巴上接一个字符
cur.deleteCharAt(...)退回路口前,把刚写的那一笔擦掉

C++ 版

同一套思路,C++ 把StringBuilder换成string,加一笔是push_back,擦一笔是pop_back。

classSolution{public:vector<string>generateParenthesis(intn){vector<string>ans;string cur;// 正在拼的串(全树共用同一个对象)// 从空串、两个计数器归零开始搜backtrack(ans,cur,0,0,n);returnans;}// ans 装答案// cur 正在拼的串(全树共用同一个对象)// open 已经用了几个 '('// close 已经用了几个 ')'// max 就是 n,一共要几对voidbacktrack(vector<string>&ans,string&cur,intopen,intclose,intmax){// 位置填满了,2n 个字符全用完,这时候的串一定合法if(cur.size()==max*2){ans.push_back(cur);// 存一份拷贝,不是存这个对象的引用return;}// 出口 A:左括号还有剩,可以放一个if(open<max){cur.push_back('(');backtrack(ans,cur,open+1,close,max);cur.pop_back();// 撤销这一步}// 出口 B:右括号比左括号少,才能放,否则会出现非法前缀if(close<open){cur.push_back(')');backtrack(ans,cur,open,close+1,max);cur.pop_back();// 撤销这一步}}};

Python 版

Python 的字符串没法原地改,把字符先攒在一个列表里,收进答案时再拼成串;参数名也换成left和right,避开内置函数open。

classSolution:defgenerateParenthesis(self,n:int)->list[str]:ans=[]# cur 正在拼的串(全树共用同一个列表)# left 已经用了几个 '('# right 已经用了几个 ')'defbacktrack(cur,left,right):# 位置填满了,2n 个字符全用完,这时候的串一定合法iflen(cur)==n*2:ans.append("".join(cur))# 存一份拼好的串,不是存这个列表return# 出口 A:左括号还有剩,可以放一个ifleft<n:cur.append('(')backtrack(cur,left+1,right)cur.pop()# 撤销这一步# 出口 B:右括号比左括号少,才能放,否则会出现非法前缀ifright<left:cur.append(')')backtrack(cur,left,right+1)cur.pop()# 撤销这一步backtrack([],0,0)returnans

四个容易写错的地方

cur.toString()不能省

ans声明的是字符串列表,每个位置只收String;

cur是个StringBuilder,直接写ans.add(cur)塞进去,类型对不上,编译就过不了。

而toString()恰好办成了两件事。

它照着cur当下的内容造一条新字符串,收进答案的是这条新串,等于给这一刻的白板拍了张快照。

cur全树只有一份,后面还要接着写、接着擦,要是把cur本身收进ans,白板每变一次,已经收进去的每一条都跟着一起变,到最后全长得一模一样。

deleteCharAt不能省

deleteCharAt干的就是撤销:

把刚接上去的那个字符拿掉。

少了它,所有分支共用一条只增不减的串,回溯就名不副实。

条件是close < open,别写成close < max

写成close < max,只是判断右括号还有没有剩,没有检查手上的左括号够不够配。

open == close的时候它也放行,第一步就能写出")"这样的开头,非法前缀源源不断地混进答案。

open和close回来不用还原

它们是int形参,每层递归都有自己的副本。

这一层写open + 1,传进去的是新值,回来时这一层的open没动过。

整棵树真正共用的只有cur那一个对象,所以只有它需要手动撤销。

数量规律

合法组合的个数有个名字,叫卡特兰数:

n123458
答案个数12514421430

题目给的n <= 8,最多 1430 个结果,这样一路搜到底稳稳能过。

时间上,不合法的分支在刚冒头时就剪掉了,树上的每个节点都是合法前缀,只做常数次判断,写一笔、擦一笔也只花常数时间;

收进答案时,每个结果还要复制成一条长度2n的串。

总时间和输出规模同量级,记成O(n × Cₙ),Cₙ就是上面那张表里第n个卡特兰数。

空间上,递归栈的深度最多2n,每层只存常数个变量,cur最多存2n个字符,这两块合起来是O(n);

另外还要存下全部结果,Cₙ条、每条长2n,这部分就是输出的体量。

不算结果,额外空间是O(n);

连结果一起算,总空间也是O(n × Cₙ)。

收个尾

括号生成是回溯法的最小完整样例。

两个计数器管合法性,两个出口管分支,加一步、递归、撤一步。

这套结构能复用的地方比想象中多。

子集、全排列、组合总和、分割字符串,都是同一个动作:

往前走一步,走完退回来,换下一个选择。

把这一道吃透,后面一整片题都是换汤不换药。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/3 7:32:21

2026安卓一体机工厂深度横评:选型避坑与供应链真相

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 7:31:55

Docker -- 构建ruoyi-system镜像

基础先构建一下maven&#xff0c;打jar包出来把jar包复制到jar目录构建文件dockerffil文件# 基础镜像 FROM eclipse-temurin:17-jdk # author MAINTAINER kane# 挂载目录 VOLUME /home/kane # 创建目录 RUN mkdir -p /home/kane # 指定路径 WORKDIR /home/kane # 复制jar文件到…

作者头像 李华
网站建设 2026/10/3 7:31:03

毕业论文降AI实战:知网AIGC检测原理与三款工具对比

1. 为什么我的论文会被判定为AI生成&#xff1f;“毕业论文降AI”这个话题&#xff0c;几乎成了每年答辩季的必修课。我写这篇文章的起因很简单&#xff1a;自己带了几届学生的毕业设计&#xff0c;发现越来越多同学交上来的初稿&#xff0c;用知网、万方的AIGC检测系统一查&am…

作者头像 李华
网站建设 2026/10/3 7:30:52

Coucou的7个一键集成:Stripe、GitHub、n8n服务都有专属彩色Mochi

Coucou的7个一键集成&#xff1a;Stripe、GitHub、n8n服务都有专属彩色Mochi 【免费下载链接】coucou A tiny friend that lives in your notch (macOS) or at the top of your screen (Windows, Linux) and keeps an eye on your coding agents: Claude Code, Gemini CLI, Ant…

作者头像 李华