LeetCode 1087. 花括号展开
题目描述
题意分析
输入是一个由小写字母、逗号和花括号组成的字符串。它可以被理解成若干个「槽位」从左到右串起来:一个裸露的字母是只有一种取值的槽位,一对花括号
{x,y,z}是有多种取值的槽位。要求输出所有可能的字符串,也就是每个槽位各挑一个字母拼起来的全部组合,并且结果必须按字典序排列。
约束里有几条决定性的信息。花括号不会嵌套,这意味着不需要递归文法解析,一次从左到右的线性扫描就能把整个串切成槽位序列。花括号里的每一项都是单个小写字母,所以「解析一个选项」等价于「读一个字符」,不用处理多字符 token。字符串长度上限很小(五十以内),槽位数量因此有限,结果规模虽然是各槽位选项数的乘积,但在这个规模下完全可以把所有结果显式生成出来,说明这题考的是「把结构解析对、把组合枚举全」,而不是任何计数或压缩技巧。
边界有三处需要留意:花括号内部的字母不保证已经排好序,题目给的例子里就出现过
{b,a}这种形式,所以不能假设扫描顺序就是字典序;输入可能完全没有花括号,此时答案只有一个字符串;单个花括号里也可能只有一个字母,退化成和裸字母等价的槽位,代码不该为它单开分支。
解法:逐段展开
核心思路
最直接的想法是回溯:写一个递归函数,参数是「当前解析到的下标」和「已经拼好的前缀」,遇到字母就把它接到前缀上往后递归,遇到花括号就先把括号内的所有字母收集出来,对每个字母各递归一次。这个做法是对的,但它有个不太舒服的地方:解析下标的推进逻辑和递归的分支逻辑纠缠在一起,写在白板上很容易把「括号闭合后下标应该跳到哪」写错,而且每条分支都要把括号重新解析一遍,做了大量重复的解析工作。
换个角度观察这个结构:由于花括号不嵌套,整个串其实就是槽位的线性拼接,槽位之间是完全独立的——第二个槽位选什么,跟第一个槽位选了什么毫无关系。既然如此,就没必要用递归去表达这种独立性,直接按顺序把槽位一个个「乘」进结果集合即可,这就是笛卡尔积的迭代形式。解析只做一遍,组合过程变成纯粹的两层循环,逻辑上比回溯更平坦。
显式写出这个算法维护的不变量:设
res是当前的结果集合,i是解析指针,那么任意时刻恒有「res恰好等于s的前缀s[0..i)所能展开出的全部字符串,不重不漏」。初始时i = 0、res = [""],空前缀只能展开出空串,不变量成立。每一轮从i处解析出一个完整槽位,得到它的候选字母列表options,用res × options生成新的res并把i推到槽位之后,不变量依然成立——因为新前缀的任何展开结果,都唯一地分解为「旧前缀的某个展开」拼上「本槽位的某个候选」。当i走到串尾时,前缀就是全串,res即为答案集合。
最后剩一个字典序的问题。上面的过程只保证结果集合正确,不保证有序:花括号内的字母顺序会原样传导到结果的相对顺序上,
{b,a}就会先产出b开头的串。与其在解析时对每个options排序(那样确实也能让生成顺序天然有序,因为前缀相同时后缀的顺序就是字典序),不如在最后对结果整体排一次序,逻辑更直白,也不依赖「前缀有序推出整体有序」这个需要额外论证的性质。
解题步骤
- 初始化
res为只含空串的列表,解析指针i = 0。用空串而不是空列表作为起点,是因为空列表在做笛卡尔积时会把一切都乘成空,而空串是拼接运算的单位元,能让第一个槽位自然地生成初始结果。
- 进入主循环,条件是
i < s.length()。每一轮的职责固定为两件事:解析出一个槽位的候选列表,然后用它更新res。把这两件事严格分开,是为了让「不管槽位是裸字母还是花括号,后半段的合并代码完全一致」,避免写两套重复的乘积逻辑。
- 解析时先看
s.charAt(i)是不是{。如果是,先i++跳过左括号,然后一路前进直到遇到},途中把非逗号的字符收进options,最后再i++跳过右括号。用「跳过所有逗号」而不是「按逗号切分」,是因为选项保证是单字符,遇到字母就收即可,不需要维护 token 缓冲区。两次i++一个都不能少,否则指针会停在括号上导致死循环或误解析。
- 如果当前字符不是
{,说明是一个裸字母,直接把它作为唯一候选放进options并i++。这条分支让裸字母和只有一个选项的花括号在后续处理上完全等价。
- 合并阶段新建一个
next列表,外层遍历res中的每个前缀,内层遍历options中的每个候选,把拼接结果放进next,最后令res = next。必须新建列表而不是原地修改res,否则一边遍历一边追加会读到本轮刚生成的元素,导致同一个槽位被重复乘上去。
- 全部解析完后对
res做一次字典序排序再返回。排序放在最后而不是解析中途,是因为只有此时每个字符串才是完整的,中途排序对最终顺序没有任何保证。
以
s = "{a,b}c{d,e}f"走一遍。初始res = [""],i = 0。第一轮,s[0]是{,i推到 1,依次读到a(收入)、,(跳过)、b(收入),i停在 4 处的},再i++变成 5,options = ["a","b"],合并后res = ["a","b"]。第二轮i = 5,s[5]是裸字母c,options = ["c"],i变成 6,合并后res = ["ac","bc"]——注意这里options只有一个元素,乘积等于给每个前缀统一追加一个字符,规模没有膨胀。第三轮i = 6又是{,同样解析出options = ["d","e"],i推到 11,合并后res = ["acd","ace","bcd","bce"],元素数从 2 翻到 4。第四轮i = 11,裸字母f,options = ["f"],i变成 12,res = ["acdf","acef","bcdf","bcef"]。循环退出,排序后顺序不变,返回这四个字符串。如果把输入换成"{b,a}c",第一轮的options会是["b","a"],中途res = ["bc","ac"]是乱序的,正是最后那次排序把它纠正成["ac","bc"]。
代码实现
class Solution {
// 遇到花括号时解析备选字符列表,与当前结果做笛卡尔积。
public String[] expand(String s) {
List<String> res = new ArrayList<>();
res.add("");
int i = 0;
while (i < s.length()) {
List<String> options = new ArrayList<>();
if (s.charAt(i) == '{') {
i++;
while (i < s.length() && s.charAt(i) != '}') {
if (s.charAt(i) != ',') {
options.add(String.valueOf(s.charAt(i)));
}
i++;
}
i++;
} else {
options.add(String.valueOf(s.charAt(i)));
i++;
}
List<String> next = new ArrayList<>();
for (String prefix : res) {
for (String opt : options) {
next.add(prefix + opt);
}
}
res = next;
}
Collections.sort(res);
return res.toArray(new String[0]);
}
}
func expand(s string) []string {
// 遇到花括号时解析备选字符列表,与当前结果做笛卡尔积。
res := []string{""}
i := 0
for i < len(s) {
options := make([]string, 0)
if s[i] == '{' {
i++
for i < len(s) && s[i] != '}' {
if s[i] != ',' {
options = append(options, string(s[i]))
}
i++
}
i++
} else {
options = append(options, string(s[i]))
i++
}
next := make([]string, 0)
for _, prefix := range res {
for _, opt := range options {
next = append(next, prefix+opt)
}
}
res = next
}
sort.Strings(res)
return res
}
复杂度分析
- 时间复杂度:$O(k \cdot r \log r)$,其中 $r$ 为结果数量(等于各槽位候选数的乘积),$k$ 为单个结果的长度。解析只扫一遍原串是 $O(n)$;生成阶段每个最终结果都被逐字符构造出来,是 $O(k \cdot r)$;排序做 $O(r \log r)$ 次字符串比较,每次比较代价 $O(k)$,这一项主导了总量。
- 空间复杂度:$O(k \cdot r)$,凭的是必须把全部 $r$ 个长度为 $k$ 的结果同时保存下来才能排序并返回,其余只有一个大小不超过字符集的候选列表和几个指针。
关键点总结
- 只要子结构之间彼此独立、没有嵌套依赖,就可以把回溯改写成迭代的笛卡尔积。判断标准是问自己「后面的选择会不会受前面选择的影响」,答案是「不会」时,两层循环就够了,不必上递归。
- 用空串而不是空列表作为结果集合的初值,是所有增量式组合枚举的通用起手式:它是拼接运算的单位元,能让第一轮循环和后续循环走完全相同的代码路径。
- 解析和合并两个阶段要严格分离。解析只负责「把指针推到下一个槽位并产出候选列表」,合并只负责乘积,这样裸字母和花括号共用同一段合并代码,分支数量减半,白板上出错的机会也随之减半。
- 迭代地扩展集合时必须写进新容器再整体替换,绝不能一边遍历一边往同一个容器里追加,这是这类写法最典型的自我污染陷阱。
- 面试视角上,被问到这题应该主动说明两点:一是「花括号不嵌套」这条约束是选择线性扫描而非递归下降解析的依据,二是如果面试官追问嵌套情况(也就是 1096),解法必须换成递归文法解析并用集合去重,这两问基本就是这道题的全部考察意图。
- 顺序要求要单独当成一个阶段处理。生成过程只保证集合正确,字典序是额外的输出约束,把它放在最后一次排序里解决,比试图让生成顺序天然有序更稳妥,也更容易向面试官解释正确性。
易错点总结
- 错误写法:把
res初始化成空列表new ArrayList<>()而不是含空串的列表。用例"abc",第一轮合并时外层循环一次都不执行,next始终为空,最终返回空数组。
- 错误写法:解析花括号后忘记最后那个
i++,指针停在}上。用例"{a,b}c",下一轮读到}会走裸字母分支,把}当成一个候选字符拼进结果,输出["a}c","b}c"]。
- 错误写法:进入花括号分支后没有先
i++跳过{。用例"{a,b}",内层循环第一次就读到{并把它收进options,结果里混入括号字符,同时指针推进节奏全乱。
- 错误写法:内层解析循环的条件只写
s.charAt(i) != '}',漏掉i < s.length()。用例是任何以花括号结尾且解析出错导致越过右括号的情况,会抛出字符串下标越界异常。
- 错误写法:在合并阶段直接对
res原地追加,写成for (String prefix : res) res.add(prefix + opt);。用例"{a,b}"会在遍历中修改集合,Java 里直接抛ConcurrentModificationException,Go 里则会读到本轮新加的元素并无限膨胀。
- 错误写法:合并时把两层循环写反,外层遍历
options、内层遍历res,同时仍然原地覆盖res。用例"{a,b}{c,d}"会漏掉部分组合,输出只有两个结果而不是四个。
- 错误写法:省掉最后的排序,认为按扫描顺序生成就是字典序。用例
"{b,a}c"会输出["bc","ac"],字典序不满足题目要求。
- 错误写法:改成在每次解析出
options后对options排序,但仍然把结果原样返回且中途某个槽位是裸字母。这条本身可行,但如果只对花括号分支的options排序而漏掉整体校验,用例"{b,a}"单独测能过,"{c,a}{b,a}"这类多槽位组合一旦合并顺序写反就会漏掉排序保护,输出乱序。
- 错误写法:把逗号也收进
options,即去掉if (s.charAt(i) != ',')判断。用例"{a,b}"会得到["a", ",", "b"]三个候选,输出里出现含逗号的字符串。
- 错误写法:用
s.split("[{}]")之类的正则一把切开来做。用例"a{b,c}d"切出的片段无法区分「这一段原本在括号里」还是「原本是裸字母」,空片段的产生位置也依赖实现细节,恢复槽位语义比手写指针扫描更容易出错。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 17. 电话号码的字母组合 | 中等 | 槽位候选来自固定映射表,是笛卡尔积最纯粹的形态 |
| 22. 括号生成 | 中等 | 候选由合法性剪枝动态决定,无法预先列成独立槽位 |
| 39. 组合总和 | 中等 | 元素可重复选取,靠剩余目标值剪枝而非固定槽位数 |
| 46. 全排列 | 中等 | 每层候选随已用集合变化,槽位之间不再独立 |
| 77. 组合 | 中等 | 靠起始下标单调递增来天然去重,不需要额外集合 |
| 93. 复原 IP 地址 | 中等 | 分段长度不定,切分点要配合数值合法性一起枚举 |
| 131. 分割回文串 | 中等 | 切分点合法性依赖回文判断,常配预处理表加速 |
| 394. 字符串解码 | 中等 | 括号可嵌套,必须用栈保存外层上下文再回填 |
| 726. 原子的数量 | 困难 | 嵌套括号加乘数下推,还要按字典序输出计数结果 |
| 784. 字母大小写全排列 | 中等 | 每个字母槽位固定两种取值,数字位则只有一种 |
| 1096. 花括号展开 II | 困难 | 花括号可嵌套且含并集语义,需要递归文法解析并对结果去重 |